From f096a14deb46180847bb74027b774a1417f8b380 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:11:50 +0000 Subject: [PATCH 01/79] build: gate the whole library's unit-test coverage at 100% Extends the coverage gate from the Android-specific layer to every class in the library, Java and Kotlin: - coverageReport / coverageVerification / verifyCoverage replace the androidLayer* tasks and cover ch.qos.logback.** and org.slf4j.impl.**, with the same rule: 100% of each class's lines and branches. - scripts/coverage-gaps.py lists the lines and branches a JaCoCo report misses. CI writes both variants' reports first and prints their gaps to the log and the job summary, then runs the gate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .github/copilot-instructions.md | 6 ++- .github/workflows/build.yml | 26 ++++++++--- logback-android/build.gradle | 40 ++++++++--------- scripts/coverage-gaps.py | 76 +++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 28 deletions(-) create mode 100755 scripts/coverage-gaps.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 00ff2c7f8..5dc1895b5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -17,8 +17,10 @@ sockets, syslog, email). - **Tests:** JUnit tests live under `logback-android/src/test/java`, except the tests of the Android-specific layer (`ch.qos.logback.{classic,core}.android`), which are written in Kotlin under `logback-android/src/test/kotlin`. Run with - `./gradlew test`. That layer is gated at 100% line and branch coverage - (`./gradlew verifyAndroidLayerCoverage`); keep it there when changing it. + `./gradlew test`. The whole library, Java and Kotlin, is gated at 100% line + and branch coverage (`./gradlew verifyCoverage`; `scripts/coverage-gaps.py` + lists what a report misses); a change must come with the tests that keep it + there. - **Upstream parity:** Much of the code mirrors upstream logback. Prefer changes that stay close to upstream behavior and naming so the port remains easy to sync. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 02b4e9cb3..66bc60576 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -92,18 +92,32 @@ jobs: - name: Unit tests (jdk8 variant) run: ./gradlew testJdk8DebugUnitTest - # The Android-specific layer (ch.qos.logback.{classic,core}.android) must - # stay at 100% line and branch coverage (rule in logback-android/build.gradle). - # Reuses the coverage the unit test runs above recorded. - - name: Coverage gate (Android-specific layer) - run: ./gradlew verifyAndroidLayerCoverage androidLayerCoverageReportJdk11Debug + # Every class in the library, Java and Kotlin, must stay at 100% line and + # branch coverage (rule in logback-android/build.gradle). Reuses the + # coverage the unit test runs above recorded. The reports are written + # first so that the next step can list what a failing gate is missing. + - name: Coverage reports + run: ./gradlew coverageReportJdk11Debug coverageReportJdk8Debug + + - name: Coverage gaps + run: | + for variant in Jdk11Debug Jdk8Debug; do + report="logback-android/build/reports/jacoco/coverageReport$variant/coverageReport$variant.xml" + echo "::group::Uncovered lines and branches ($variant)" + python3 scripts/coverage-gaps.py "$report" + echo "::endgroup::" + { echo "### Uncovered lines and branches ($variant)"; echo '```'; python3 scripts/coverage-gaps.py "$report"; echo '```'; } >> "$GITHUB_STEP_SUMMARY" + done + + - name: Coverage gate + run: ./gradlew verifyCoverage - name: Upload coverage report if: ${{ !cancelled() && matrix.java == 21 }} uses: actions/upload-artifact@v7 with: name: coverage-report - path: logback-android/build/reports/jacoco/androidLayerCoverageReportJdk11Debug/ + path: logback-android/build/reports/jacoco/coverageReportJdk11Debug/ - name: Upload test reports if: failure() diff --git a/logback-android/build.gradle b/logback-android/build.gradle index a5e53a6eb..e75101c3e 100644 --- a/logback-android/build.gradle +++ b/logback-android/build.gradle @@ -63,7 +63,7 @@ android { debug { debuggable true // Records JaCoCo coverage of the debug unit tests, which the - // Android-layer coverage gate at the end of this file checks. + // coverage gate at the end of this file checks. enableUnitTestCoverage true } } @@ -137,37 +137,37 @@ afterEvaluate { } } -// Coverage gate for the Android-specific layer (ch.qos.logback.{classic,core}.android): -// its unit tests must cover 100% of its lines and branches, so that they pin -// the behavior of every path. For each debug variant, -// androidLayerCoverageReport writes an HTML/XML report and -// androidLayerCoverageVerification enforces the rule; -// verifyAndroidLayerCoverage runs both variants' checks. -def androidLayerClasses = ['ch/qos/logback/classic/android/**', 'ch/qos/logback/core/android/**'] -def androidLayerCoverage = { JacocoReportBase task, String variant -> +// Coverage gate for the whole library, Java and Kotlin: the unit tests must +// cover 100% of the lines and branches of every class, so that they pin the +// behavior of every path. For each debug variant, coverageReport +// writes an HTML/XML report (scripts/coverage-gaps.py lists what it misses) +// and coverageVerification enforces the rule; verifyCoverage runs +// both variants' checks. +def libraryClasses = ['ch/qos/logback/**', 'org/slf4j/impl/**'] +def libraryCoverage = { JacocoReportBase task, String variant -> def testTask = tasks.named("test${variant}UnitTest", Test) task.dependsOn(testTask) task.executionData.from(testTask.map { it.extensions.getByType(JacocoTaskExtension).destinationFile }) task.classDirectories.from(files( tasks.named("compile${variant}Kotlin").flatMap { it.destinationDirectory }, tasks.named("compile${variant}JavaWithJavac").flatMap { it.destinationDirectory }, - ).asFileTree.matching { include androidLayerClasses }) + ).asFileTree.matching { include libraryClasses }) task.sourceDirectories.from(files('src/main/kotlin', 'src/main/java')) } -def androidLayerVerifications = ['Jdk11Debug', 'Jdk8Debug'].collect { variant -> - tasks.register("androidLayerCoverageReport${variant}", JacocoReport) { +def coverageVerifications = ['Jdk11Debug', 'Jdk8Debug'].collect { variant -> + tasks.register("coverageReport${variant}", JacocoReport) { group = 'verification' - description = "Reports the ${variant} unit tests' coverage of the Android-specific layer." - androidLayerCoverage(it, variant) + description = "Reports the ${variant} unit tests' coverage of the library." + libraryCoverage(it, variant) reports { html.required = true xml.required = true } } - tasks.register("androidLayerCoverageVerification${variant}", JacocoCoverageVerification) { + tasks.register("coverageVerification${variant}", JacocoCoverageVerification) { group = 'verification' - description = "Fails unless the ${variant} unit tests cover every line and branch of the Android-specific layer." - androidLayerCoverage(it, variant) + description = "Fails unless the ${variant} unit tests cover every line and branch of the library." + libraryCoverage(it, variant) violationRules { rule { element = 'CLASS' @@ -177,10 +177,10 @@ def androidLayerVerifications = ['Jdk11Debug', 'Jdk8Debug'].collect { variant -> } } } -tasks.register('verifyAndroidLayerCoverage') { +tasks.register('verifyCoverage') { group = 'verification' - description = 'Fails unless the unit tests cover every line and branch of the Android-specific layer.' - dependsOn androidLayerVerifications + description = 'Fails unless the unit tests cover every line and branch of the library.' + dependsOn coverageVerifications } dependencies { diff --git a/scripts/coverage-gaps.py b/scripts/coverage-gaps.py new file mode 100755 index 000000000..194067ce0 --- /dev/null +++ b/scripts/coverage-gaps.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# +# Lists what a JaCoCo XML report leaves uncovered, one source file per line: +# +# ch/qos/logback/core/FileAppender.java: missed lines 120-122; partly-missed +# lines 88; missed branches L88(1/2) +# +# "L88(1/2)" means one of the two branches on line 88 never ran. The last line +# is the total. logback-android's coverage gate (verifyCoverage) requires every +# line and branch of the library to be covered, so this is the list of what a +# failing gate is missing, without downloading the HTML report. +# +# Run: ./gradlew coverageReportJdk11Debug +# scripts/coverage-gaps.py logback-android/build/reports/jacoco/coverageReportJdk11Debug/coverageReportJdk11Debug.xml + +import re +import sys +import xml.etree.ElementTree as ET + + +def ranges(numbers): + """[1, 2, 3, 7] -> '1-3,7'""" + spans = [] + for n in sorted(numbers): + if spans and n == spans[-1][1] + 1: + spans[-1][1] = n + else: + spans.append([n, n]) + return ','.join(str(a) if a == b else f'{a}-{b}' for a, b in spans) + + +def gaps(report): + with open(report, encoding='utf-8') as f: + # JaCoCo's DOCTYPE names a DTD that isn't shipped with the report + root = ET.fromstring(re.sub(r']*>', '', f.read(), count=1)) + totals = {'LINE': [0, 0], 'BRANCH': [0, 0]} + for counter in root.findall('counter'): + if counter.get('type') in totals: + totals[counter.get('type')] = [int(counter.get('missed')), int(counter.get('covered'))] + files = [] + for package in root.iter('package'): + for source in package.findall('sourcefile'): + missed, partly, branches = [], [], [] + for line in source.findall('line'): + nr, mi, ci, mb, cb = (int(line.get(k)) for k in ('nr', 'mi', 'ci', 'mb', 'cb')) + if mi and not ci: + missed.append(nr) + elif mi: + partly.append(nr) + if mb: + branches.append(f'L{nr}({mb}/{mb + cb})') + parts = [] + if missed: + parts.append(f'missed lines {ranges(missed)}') + if partly: + parts.append(f'partly-missed lines {ranges(partly)}') + if branches: + parts.append('missed branches ' + ', '.join(branches)) + if parts: + files.append(f"{package.get('name')}/{source.get('name')}: " + '; '.join(parts)) + return sorted(files), totals + + +def main(argv): + if len(argv) != 2: + sys.exit(f'usage: {argv[0]} ') + files, totals = gaps(argv[1]) + for line in files: + print(line) + (lm, lc), (bm, bc) = totals['LINE'], totals['BRANCH'] + print(f'{len(files)} file(s) with gaps; lines {lc}/{lc + lm} covered, ' + f'branches {bc}/{bc + bm} covered') + + +if __name__ == '__main__': + main(sys.argv) From 287395ffe05a86eef75ad3815cc5158e4be1e1f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:23:45 +0000 Subject: [PATCH 02/79] docs: run the coverage gate before pushing The phase-start checklist lists what CI runs; CI now also runs verifyCoverage. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .claude/skills/phase-start/SKILL.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.claude/skills/phase-start/SKILL.md b/.claude/skills/phase-start/SKILL.md index ae68f9d2b..59456b5f9 100644 --- a/.claude/skills/phase-start/SKILL.md +++ b/.claude/skills/phase-start/SKILL.md @@ -85,10 +85,14 @@ flavors separately. Write the failing test first where the phase fixes a bug. Before pushing, run what CI runs: ``` -./gradlew lint testJdk11DebugUnitTest testJdk8DebugUnitTest +./gradlew lint testJdk11DebugUnitTest testJdk8DebugUnitTest verifyCoverage ./scripts/check-architecture-docs.sh ``` +`verifyCoverage` fails unless the unit tests cover every line and branch of every class, so new +or changed code comes with the tests that cover it. When it fails, `./gradlew +coverageReportJdk11Debug` and `scripts/coverage-gaps.py` on the XML report list what is missing. + Open the PR as a draft (see `AGENTS.md`), with a Conventional Commits title and a short "deferred work considered" note naming what you folded in and what you deliberately left. Two lines. It is the record that the check happened, and it is what lets the next phase trust that From ac78c73e0134676f0bb7dfc096499b641d7a8bcc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:29:24 +0000 Subject: [PATCH 03/79] test: cover classic spi value objects (LoggerContextVO, LoggingEventVO, ThrowableProxyVO) Add LoggerContextVOTest, LoggingEventVOTest and ThrowableProxyVOTest. They pin what LoggingEventVO.build copies from its source (caller data only when present), the lazily computed and cached formatted message, the context accessors and getMdc, ThrowableProxyVO.build converting the whole cause chain and every suppressed proxy, toString of LoggerContextVO, and every null/non-null branch of equals/hashCode of the three classes (including the exact-class check of the event and throwable VOs). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../classic/spi/LoggerContextVOTest.java | 175 ++++++++++ .../classic/spi/LoggingEventVOTest.java | 308 ++++++++++++++++++ .../classic/spi/ThrowableProxyVOTest.java | 233 +++++++++++++ 3 files changed, 716 insertions(+) create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/spi/LoggerContextVOTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/spi/LoggingEventVOTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/spi/ThrowableProxyVOTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggerContextVOTest.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggerContextVOTest.java new file mode 100644 index 000000000..7cbe9f918 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggerContextVOTest.java @@ -0,0 +1,175 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.spi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; + +import ch.qos.logback.classic.LoggerContext; + +public class LoggerContextVOTest { + + static final long BIRTH_TIME = 0x123456789AL; + + static Map props(String key, String value) { + Map map = new HashMap(); + map.put(key, value); + return map; + } + + static String distinctCopy(String s) { + return new StringBuilder(s).toString(); + } + + @Test + public void threeArgConstructorExposesGivenValues() { + Map map = props("k", "v"); + LoggerContextVO vo = new LoggerContextVO("ctx", map, BIRTH_TIME); + + assertEquals("ctx", vo.getName()); + assertSame(map, vo.getPropertyMap()); + assertEquals(BIRTH_TIME, vo.getBirthTime()); + } + + @Test + public void loggerContextConstructorCopiesNamePropertiesAndBirthTime() { + LoggerContext lc = new LoggerContext(); + lc.setName("fromContext"); + lc.putProperty("key", "value"); + + LoggerContextVO vo = new LoggerContextVO(lc); + + assertEquals("fromContext", vo.getName()); + assertEquals("value", vo.getPropertyMap().get("key")); + assertEquals(lc.getBirthTime(), vo.getBirthTime()); + + // the property map is a snapshot, later changes to the context are not seen + lc.putProperty("late", "x"); + assertFalse(vo.getPropertyMap().containsKey("late")); + } + + @Test + public void toStringListsNamePropertiesAndBirthTime() { + LoggerContextVO vo = new LoggerContextVO("ctx", props("k", "v"), 42L); + assertEquals("LoggerContextVO{name='ctx', propertyMap={k=v}, birthTime=42}", vo.toString()); + } + + @Test + public void toStringRendersNullFieldsAsNull() { + LoggerContextVO vo = new LoggerContextVO(null, null, 0L); + assertEquals("LoggerContextVO{name='null', propertyMap=null, birthTime=0}", vo.toString()); + } + + @Test + public void equalsIsReflexive() { + LoggerContextVO vo = new LoggerContextVO("ctx", props("k", "v"), BIRTH_TIME); + assertTrue(vo.equals(vo)); + } + + @Test + public void equalsRejectsNullAndOtherTypes() { + LoggerContextVO vo = new LoggerContextVO("ctx", props("k", "v"), BIRTH_TIME); + assertFalse(vo.equals(null)); + assertFalse(vo.equals("ctx")); + } + + @Test + public void equalsHoldsForSameValuesInDistinctInstances() { + LoggerContextVO a = new LoggerContextVO("ctx", props("k", "v"), BIRTH_TIME); + LoggerContextVO b = new LoggerContextVO(distinctCopy("ctx"), props("k", "v"), BIRTH_TIME); + assertNotSame(a.getName(), b.getName()); + assertTrue(a.equals(b)); + assertTrue(b.equals(a)); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + public void equalsHoldsWhenNameAndPropertiesAreNullOnBothSides() { + LoggerContextVO a = new LoggerContextVO(null, null, BIRTH_TIME); + LoggerContextVO b = new LoggerContextVO(null, null, BIRTH_TIME); + assertTrue(a.equals(b)); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + public void equalsFailsWhenBirthTimeDiffers() { + LoggerContextVO a = new LoggerContextVO("ctx", props("k", "v"), 1L); + LoggerContextVO b = new LoggerContextVO("ctx", props("k", "v"), 2L); + assertFalse(a.equals(b)); + } + + @Test + public void equalsFailsWhenNamesDiffer() { + LoggerContextVO a = new LoggerContextVO("ctx", null, BIRTH_TIME); + LoggerContextVO b = new LoggerContextVO("other", null, BIRTH_TIME); + assertFalse(a.equals(b)); + } + + @Test + public void equalsFailsWhenOnlyOneNameIsNull() { + LoggerContextVO named = new LoggerContextVO("ctx", null, BIRTH_TIME); + LoggerContextVO unnamed = new LoggerContextVO(null, null, BIRTH_TIME); + assertFalse(named.equals(unnamed)); + assertFalse(unnamed.equals(named)); + } + + @Test + public void equalsFailsWhenPropertyMapsDiffer() { + LoggerContextVO a = new LoggerContextVO("ctx", props("k", "v"), BIRTH_TIME); + LoggerContextVO b = new LoggerContextVO("ctx", props("k", "other"), BIRTH_TIME); + assertFalse(a.equals(b)); + } + + @Test + public void equalsFailsWhenOnlyOnePropertyMapIsNull() { + LoggerContextVO withMap = new LoggerContextVO("ctx", Collections.emptyMap(), BIRTH_TIME); + LoggerContextVO withoutMap = new LoggerContextVO("ctx", null, BIRTH_TIME); + assertFalse(withMap.equals(withoutMap)); + assertFalse(withoutMap.equals(withMap)); + } + + @Test + public void hashCodeOfNullNameAndPropertiesDependsOnlyOnBirthTime() { + LoggerContextVO vo = new LoggerContextVO(null, null, BIRTH_TIME); + assertEquals((int) (BIRTH_TIME ^ (BIRTH_TIME >>> 32)), vo.hashCode()); + } + + @Test + public void hashCodeCombinesNamePropertiesAndBirthTime() { + Map map = props("k", "v"); + LoggerContextVO vo = new LoggerContextVO("ctx", map, BIRTH_TIME); + int expected = 31 * (31 * "ctx".hashCode() + map.hashCode()) + (int) (BIRTH_TIME ^ (BIRTH_TIME >>> 32)); + assertEquals(expected, vo.hashCode()); + } + + @Test + public void hashCodeChangesWithEachField() { + LoggerContextVO base = new LoggerContextVO("ctx", props("k", "v"), BIRTH_TIME); + assertNotEquals(base.hashCode(), new LoggerContextVO("other", props("k", "v"), BIRTH_TIME).hashCode()); + assertNotEquals(base.hashCode(), new LoggerContextVO("ctx", props("k", "w"), BIRTH_TIME).hashCode()); + assertNotEquals(base.hashCode(), new LoggerContextVO("ctx", props("k", "v"), BIRTH_TIME + 1).hashCode()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggingEventVOTest.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggingEventVOTest.java new file mode 100644 index 000000000..3449c22eb --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggingEventVOTest.java @@ -0,0 +1,308 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.spi; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Test; +import org.slf4j.Marker; +import org.slf4j.MarkerFactory; + +import ch.qos.logback.classic.Level; + +public class LoggingEventVOTest { + + // larger than 2^32 so that the upper half of the time stamp takes part in hashCode() + static final long TIME_STAMP = 0x1234_5678_9ABCL; + + static final Marker MARKER_A = MarkerFactory.getDetachedMarker("A"); + static final Marker MARKER_B = MarkerFactory.getDetachedMarker("B"); + + /** A new String instance, equal to but not the same as {@code s}. */ + static String copyOf(String s) { + return new StringBuilder(s).toString(); + } + + static List markers(Marker... markers) { + List list = new ArrayList(); + Collections.addAll(list, markers); + return list; + } + + static Map mdc(String key, String value) { + Map map = new HashMap(); + map.put(key, value); + return map; + } + + /** + * A source event with every field that LoggingEventVO copies set. Each call + * returns fresh (equal, but not identical) field values. + */ + static PubLoggingEventVO fullSource() { + PubLoggingEventVO source = new PubLoggingEventVO(); + source.message = copyOf("hello {}"); + source.loggerName = copyOf("a.b.C"); + source.threadName = copyOf("worker-1"); + source.timeStamp = TIME_STAMP; + source.level = Level.INFO; + source.argumentArray = new Object[] { "world" }; + source.markers = markers(MARKER_A); + source.mdcPropertyMap = mdc("k", copyOf("v")); + source.loggerContextVO = new LoggerContextVO("ctx", Collections.emptyMap(), 77L); + return source; + } + + /** A full source event whose field {@code fieldName} is replaced by {@code value}. */ + static LoggingEventVO fullEventWith(String fieldName, Object value) throws Exception { + PubLoggingEventVO source = fullSource(); + PubLoggingEventVO.class.getField(fieldName).set(source, value); + return LoggingEventVO.build(source); + } + + @Test + public void buildCopiesTheStateOfTheSourceEvent() { + PubLoggingEventVO source = fullSource(); + LoggingEventVO vo = LoggingEventVO.build(source); + + assertEquals("hello {}", vo.getMessage()); + assertEquals("a.b.C", vo.getLoggerName()); + assertEquals("worker-1", vo.getThreadName()); + assertEquals(TIME_STAMP, vo.getTimeStamp()); + assertSame(Level.INFO, vo.getLevel()); + assertArrayEquals(new Object[] { "world" }, vo.getArgumentArray()); + assertSame(source.markers, vo.getMarkers()); + assertSame(source.mdcPropertyMap, vo.getMDCPropertyMap()); + assertSame(source.loggerContextVO, vo.getLoggerContextVO()); + assertNull(vo.getThrowableProxy()); + } + + @Test + public void getMdcReturnsTheMdcPropertyMap() { + PubLoggingEventVO source = fullSource(); + LoggingEventVO vo = LoggingEventVO.build(source); + + assertSame(source.mdcPropertyMap, vo.getMdc()); + assertSame(vo.getMDCPropertyMap(), vo.getMdc()); + } + + @Test + public void contextRemoteViewAndBirthTimeComeFromTheLoggerContextVO() { + PubLoggingEventVO source = fullSource(); + LoggingEventVO vo = LoggingEventVO.build(source); + + assertSame(source.loggerContextVO, vo.getContextLoggerRemoteView()); + assertEquals(77L, vo.getContextBirthTime()); + } + + @Test + public void callerDataIsCopiedWhenTheSourceHasIt() { + PubLoggingEventVO source = fullSource(); + StackTraceElement[] callerData = { new StackTraceElement("a.b.C", "m", "C.java", 12) }; + source.callerDataArray = callerData; + + LoggingEventVO vo = LoggingEventVO.build(source); + + assertTrue(vo.hasCallerData()); + assertSame(callerData, vo.getCallerData()); + } + + @Test + public void callerDataIsAbsentWhenTheSourceHasNone() { + LoggingEventVO vo = LoggingEventVO.build(fullSource()); + + assertFalse(vo.hasCallerData()); + assertNull(vo.getCallerData()); + } + + @Test + public void formattedMessageSubstitutesArguments() { + LoggingEventVO vo = LoggingEventVO.build(fullSource()); + assertEquals("hello world", vo.getFormattedMessage()); + } + + @Test + public void formattedMessageIsTheRawMessageWithoutArguments() throws Exception { + LoggingEventVO vo = fullEventWith("argumentArray", null); + assertEquals("hello {}", vo.getFormattedMessage()); + } + + @Test + public void formattedMessageIsComputedOnceAndCached() { + PubLoggingEventVO source = fullSource(); + LoggingEventVO vo = LoggingEventVO.build(source); + + String first = vo.getFormattedMessage(); + // the VO shares the argument array of its source; changing it afterwards + // must not change the already formatted message + source.argumentArray[0] = "changed"; + + assertEquals("hello world", first); + assertSame(first, vo.getFormattedMessage()); + } + + @Test + public void prepareForDeferredProcessingLeavesTheEventUnchanged() { + PubLoggingEventVO source = fullSource(); + LoggingEventVO vo = LoggingEventVO.build(source); + + vo.prepareForDeferredProcessing(); + + assertEquals(LoggingEventVO.build(fullSource()), vo); + assertEquals("worker-1", vo.getThreadName()); + assertSame(source.mdcPropertyMap, vo.getMDCPropertyMap()); + assertFalse(vo.hasCallerData()); + assertEquals("hello world", vo.getFormattedMessage()); + } + + @Test + public void equalsIsReflexive() { + LoggingEventVO vo = LoggingEventVO.build(fullSource()); + assertTrue(vo.equals(vo)); + } + + @Test + public void equalsRejectsNullAndOtherTypes() { + LoggingEventVO vo = LoggingEventVO.build(fullSource()); + assertFalse(vo.equals(null)); + assertFalse(vo.equals("hello {}")); + } + + @Test + public void equalsRejectsSubclassInstancesWithTheSameState() { + LoggingEventVO plain = LoggingEventVO.build(new PubLoggingEventVO()); + LoggingEventVO subclass = new LoggingEventVO() { + private static final long serialVersionUID = 1L; + }; + assertFalse(plain.equals(subclass)); + assertFalse(subclass.equals(plain)); + } + + @Test + public void equalsHoldsForDistinctEventsWithEqualState() { + LoggingEventVO a = LoggingEventVO.build(fullSource()); + LoggingEventVO b = LoggingEventVO.build(fullSource()); + assertNotSame(a.getMessage(), b.getMessage()); + + assertTrue(a.equals(b)); + assertTrue(b.equals(a)); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + public void equalsHoldsWhenAllComparedFieldsAreNullOnBothSides() { + LoggingEventVO a = LoggingEventVO.build(new PubLoggingEventVO()); + LoggingEventVO b = LoggingEventVO.build(new PubLoggingEventVO()); + + assertTrue(a.equals(b)); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + public void equalsComparesMessages() throws Exception { + assertFieldTakesPartInEquals("message", copyOf("hello {}"), "bye"); + } + + @Test + public void equalsComparesLoggerNames() throws Exception { + assertFieldTakesPartInEquals("loggerName", copyOf("a.b.C"), "x.y.Z"); + } + + @Test + public void equalsComparesThreadNames() throws Exception { + assertFieldTakesPartInEquals("threadName", copyOf("worker-1"), "worker-2"); + } + + @Test + public void equalsComparesMarkers() throws Exception { + assertFieldTakesPartInEquals("markers", markers(MARKER_A), markers(MARKER_B)); + } + + @Test + public void equalsComparesMdcPropertyMaps() throws Exception { + assertFieldTakesPartInEquals("mdcPropertyMap", mdc("k", "v"), mdc("k", "w")); + } + + @Test + public void equalsComparesTimeStamps() throws Exception { + LoggingEventVO base = LoggingEventVO.build(fullSource()); + LoggingEventVO later = fullEventWith("timeStamp", TIME_STAMP + 1); + + assertFalse(base.equals(later)); + assertFalse(later.equals(base)); + } + + @Test + public void hashCodeCombinesMessageThreadNameAndTimeStamp() { + LoggingEventVO vo = LoggingEventVO.build(fullSource()); + int expected = 31 * (31 * (31 + "hello {}".hashCode()) + "worker-1".hashCode()) + + (int) (TIME_STAMP ^ (TIME_STAMP >>> 32)); + assertEquals(expected, vo.hashCode()); + } + + @Test + public void hashCodeTreatsNullMessageAndThreadNameAsZero() { + PubLoggingEventVO source = new PubLoggingEventVO(); + source.timeStamp = TIME_STAMP; + LoggingEventVO vo = LoggingEventVO.build(source); + + assertEquals(31 * 31 * 31 + (int) (TIME_STAMP ^ (TIME_STAMP >>> 32)), vo.hashCode()); + } + + @Test + public void hashCodeChangesWithMessageThreadNameAndTimeStamp() throws Exception { + int base = LoggingEventVO.build(fullSource()).hashCode(); + + assertNotEquals(base, fullEventWith("message", "bye").hashCode()); + assertNotEquals(base, fullEventWith("threadName", "worker-2").hashCode()); + assertNotEquals(base, fullEventWith("timeStamp", TIME_STAMP + 1).hashCode()); + } + + /** + * Checks every null/non-null combination of {@code fieldName} in equals(): + * an equal value (distinct instance) keeps the events equal, a different + * value or a null on only one side makes them unequal, and null on both + * sides keeps them equal. + */ + private static void assertFieldTakesPartInEquals(String fieldName, Object equalValue, Object differentValue) + throws Exception { + LoggingEventVO base = LoggingEventVO.build(fullSource()); + LoggingEventVO same = fullEventWith(fieldName, equalValue); + LoggingEventVO different = fullEventWith(fieldName, differentValue); + LoggingEventVO nulled = fullEventWith(fieldName, null); + LoggingEventVO alsoNulled = fullEventWith(fieldName, null); + + assertTrue(fieldName + ": equal values", base.equals(same)); + assertFalse(fieldName + ": different values", base.equals(different)); + assertFalse(fieldName + ": different values (reversed)", different.equals(base)); + assertFalse(fieldName + ": only other is null", base.equals(nulled)); + assertFalse(fieldName + ": only this is null", nulled.equals(base)); + assertTrue(fieldName + ": null on both sides", nulled.equals(alsoNulled)); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/ThrowableProxyVOTest.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/ThrowableProxyVOTest.java new file mode 100644 index 000000000..96a39b770 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/ThrowableProxyVOTest.java @@ -0,0 +1,233 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.spi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class ThrowableProxyVOTest { + + /** A new String instance, equal to but not the same as {@code s}. */ + static String copyOf(String s) { + return new StringBuilder(s).toString(); + } + + static StackTraceElementProxy[] stackTrace(String method) { + return new StackTraceElementProxy[] { + new StackTraceElementProxy(new StackTraceElement("a.b.C", method, "C.java", 10)) }; + } + + /** A proxy with a class name, message, common frames and a one-frame stack trace; no cause, no suppressed. */ + static DummyThrowableProxy proxy(String className) { + DummyThrowableProxy proxy = new DummyThrowableProxy(); + proxy.setClassName(className == null ? null : copyOf(className)); + proxy.setMessage("message of " + className); + proxy.setCommonFramesCount(3); + proxy.setStackTraceElementProxyArray(stackTrace("run")); + return proxy; + } + + static DummyThrowableProxy proxyWithCause(String className, IThrowableProxy cause) { + DummyThrowableProxy proxy = proxy(className); + proxy.setCause(cause); + return proxy; + } + + static DummyThrowableProxy proxyWithSuppressed(String className, IThrowableProxy... suppressed) { + DummyThrowableProxy proxy = proxy(className); + proxy.setSuppressed(suppressed); + return proxy; + } + + @Test + public void buildOfNullIsNull() { + assertNull(ThrowableProxyVO.build(null)); + } + + @Test + public void buildCopiesTheStateOfTheSourceProxy() { + DummyThrowableProxy source = proxy("java.lang.Exception"); + + ThrowableProxyVO vo = ThrowableProxyVO.build(source); + + assertEquals("java.lang.Exception", vo.getClassName()); + assertEquals("message of java.lang.Exception", vo.getMessage()); + assertEquals(3, vo.getCommonFrames()); + assertSame(source.getStackTraceElementProxyArray(), vo.getStackTraceElementProxyArray()); + assertNull(vo.getCause()); + assertNull(vo.getSuppressed()); + } + + @Test + public void buildConvertsTheWholeCauseChain() { + DummyThrowableProxy root = proxy("java.io.IOException"); + DummyThrowableProxy middle = proxyWithCause("java.lang.IllegalStateException", root); + DummyThrowableProxy top = proxyWithCause("java.lang.RuntimeException", middle); + + ThrowableProxyVO vo = ThrowableProxyVO.build(top); + + IThrowableProxy cause = vo.getCause(); + assertTrue(cause instanceof ThrowableProxyVO); + assertEquals("java.lang.IllegalStateException", cause.getClassName()); + assertEquals("message of java.lang.IllegalStateException", cause.getMessage()); + + IThrowableProxy rootCause = cause.getCause(); + assertTrue(rootCause instanceof ThrowableProxyVO); + assertEquals("java.io.IOException", rootCause.getClassName()); + assertNull(rootCause.getCause()); + } + + @Test + public void buildConvertsEverySuppressedProxyInOrder() { + DummyThrowableProxy first = proxy("java.lang.IllegalArgumentException"); + DummyThrowableProxy second = proxyWithCause("java.lang.IllegalStateException", proxy("java.io.IOException")); + DummyThrowableProxy source = proxyWithSuppressed("java.lang.Exception", first, second); + + ThrowableProxyVO vo = ThrowableProxyVO.build(source); + + IThrowableProxy[] suppressed = vo.getSuppressed(); + assertEquals(2, suppressed.length); + assertNotSame(source.getSuppressed(), suppressed); + assertTrue(suppressed[0] instanceof ThrowableProxyVO); + assertEquals("java.lang.IllegalArgumentException", suppressed[0].getClassName()); + assertTrue(suppressed[1] instanceof ThrowableProxyVO); + assertEquals("java.lang.IllegalStateException", suppressed[1].getClassName()); + assertEquals("java.io.IOException", suppressed[1].getCause().getClassName()); + } + + @Test + public void buildKeepsAnEmptySuppressedArrayEmpty() { + ThrowableProxyVO vo = ThrowableProxyVO.build(proxyWithSuppressed("java.lang.Exception")); + + assertEquals(0, vo.getSuppressed().length); + } + + @Test + public void hashCodeDependsOnTheClassName() { + ThrowableProxyVO vo = ThrowableProxyVO.build(proxy("java.lang.Exception")); + + assertEquals(31 + "java.lang.Exception".hashCode(), vo.hashCode()); + assertNotEquals(vo.hashCode(), ThrowableProxyVO.build(proxy("java.lang.Error")).hashCode()); + } + + @Test + public void hashCodeOfNullClassNameIsConstant() { + ThrowableProxyVO vo = ThrowableProxyVO.build(proxy(null)); + assertEquals(31, vo.hashCode()); + } + + @Test + public void equalsIsReflexive() { + ThrowableProxyVO vo = ThrowableProxyVO.build(proxy("java.lang.Exception")); + assertTrue(vo.equals(vo)); + } + + @Test + public void equalsRejectsNullAndOtherTypes() { + DummyThrowableProxy source = proxy("java.lang.Exception"); + ThrowableProxyVO vo = ThrowableProxyVO.build(source); + + assertFalse(vo.equals(null)); + assertFalse(vo.equals(source)); + assertFalse(vo.equals("java.lang.Exception")); + } + + @Test + public void equalsRejectsSubclassInstancesWithTheSameState() { + ThrowableProxyVO plain = ThrowableProxyVO.build(new DummyThrowableProxy()); + ThrowableProxyVO subclass = new ThrowableProxyVO() { + private static final long serialVersionUID = 1L; + }; + + assertFalse(plain.equals(subclass)); + assertFalse(subclass.equals(plain)); + } + + @Test + public void equalsHoldsForDistinctProxiesWithEqualState() { + ThrowableProxyVO a = ThrowableProxyVO.build(proxyWithSuppressed("java.lang.Exception", proxy("java.lang.Error"))); + ThrowableProxyVO b = ThrowableProxyVO.build(proxyWithSuppressed("java.lang.Exception", proxy("java.lang.Error"))); + assertNotSame(a.getClassName(), b.getClassName()); + assertNotSame(a.getStackTraceElementProxyArray(), b.getStackTraceElementProxyArray()); + + assertTrue(a.equals(b)); + assertTrue(b.equals(a)); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + public void equalsHoldsWhenClassNamesAreNullOnBothSides() { + ThrowableProxyVO a = ThrowableProxyVO.build(proxy(null)); + ThrowableProxyVO b = ThrowableProxyVO.build(proxy(null)); + assertTrue(a.equals(b)); + } + + @Test + public void equalsComparesClassNames() { + ThrowableProxyVO exception = ThrowableProxyVO.build(proxy("java.lang.Exception")); + ThrowableProxyVO error = ThrowableProxyVO.build(proxy("java.lang.Error")); + ThrowableProxyVO unnamed = ThrowableProxyVO.build(proxy(null)); + + assertFalse(exception.equals(error)); + assertFalse(exception.equals(unnamed)); + assertFalse(unnamed.equals(exception)); + } + + @Test + public void equalsComparesStackTraces() { + DummyThrowableProxy other = proxy("java.lang.Exception"); + other.setStackTraceElementProxyArray(stackTrace("call")); + + ThrowableProxyVO a = ThrowableProxyVO.build(proxy("java.lang.Exception")); + ThrowableProxyVO b = ThrowableProxyVO.build(other); + + assertFalse(a.equals(b)); + assertFalse(b.equals(a)); + } + + @Test + public void equalsComparesSuppressedProxies() { + ThrowableProxyVO none = ThrowableProxyVO.build(proxy("java.lang.Exception")); + ThrowableProxyVO withError = ThrowableProxyVO.build(proxyWithSuppressed("java.lang.Exception", proxy("java.lang.Error"))); + ThrowableProxyVO withOther = ThrowableProxyVO.build(proxyWithSuppressed("java.lang.Exception", proxy("java.io.IOException"))); + + assertFalse(none.equals(withError)); + assertFalse(withError.equals(none)); + assertFalse(withError.equals(withOther)); + } + + @Test + public void equalsComparesCauses() { + ThrowableProxyVO noCause = ThrowableProxyVO.build(proxy("java.lang.Exception")); + ThrowableProxyVO alsoNoCause = ThrowableProxyVO.build(proxy("java.lang.Exception")); + ThrowableProxyVO ioCause = ThrowableProxyVO.build(proxyWithCause("java.lang.Exception", proxy("java.io.IOException"))); + ThrowableProxyVO sameIoCause = ThrowableProxyVO.build(proxyWithCause("java.lang.Exception", proxy("java.io.IOException"))); + ThrowableProxyVO errorCause = ThrowableProxyVO.build(proxyWithCause("java.lang.Exception", proxy("java.lang.Error"))); + + assertTrue(noCause.equals(alsoNoCause)); + assertTrue(ioCause.equals(sameIoCause)); + assertFalse(noCause.equals(ioCause)); + assertFalse(ioCause.equals(noCause)); + assertFalse(ioCause.equals(errorCause)); + } +} From 993e8eb26e8de0da6fa6b010290b03f32473e27d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:37:27 +0000 Subject: [PATCH 04/79] test: pin caller-data laziness, throwable conversion and identity equals in spi VO tests Kill mutations of LoggingEventVO and LoggerContextVO that the previous tests let survive: build() computing caller data the source has not computed yet (LBCLASSIC-145), build() dropping the throwable proxy, equals() without its identity short-circuit, and the LoggerContext constructor not taking an exact copy of the property map. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../classic/spi/LoggerContextVOTest.java | 29 ++++++++++ .../classic/spi/LoggingEventVOTest.java | 56 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggerContextVOTest.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggerContextVOTest.java index 7cbe9f918..eee6c1d8f 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggerContextVOTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggerContextVOTest.java @@ -40,6 +40,21 @@ static Map props(String key, String value) { return map; } + /** A map that is not even equal to itself. */ + static final class SelfUnequalMap extends HashMap { + private static final long serialVersionUID = 1L; + + @Override + public boolean equals(Object o) { + return false; + } + + @Override + public int hashCode() { + return 0; + } + } + static String distinctCopy(String s) { return new StringBuilder(s).toString(); } @@ -60,10 +75,13 @@ public void loggerContextConstructorCopiesNamePropertiesAndBirthTime() { lc.setName("fromContext"); lc.putProperty("key", "value"); + Map expectedProperties = lc.getCopyOfPropertyMap(); + LoggerContextVO vo = new LoggerContextVO(lc); assertEquals("fromContext", vo.getName()); assertEquals("value", vo.getPropertyMap().get("key")); + assertEquals(expectedProperties, vo.getPropertyMap()); assertEquals(lc.getBirthTime(), vo.getBirthTime()); // the property map is a snapshot, later changes to the context are not seen @@ -89,6 +107,17 @@ public void equalsIsReflexive() { assertTrue(vo.equals(vo)); } + @Test + public void equalsIsTrueForTheSameInstanceWithoutComparingFields() { + Map selfUnequal = new SelfUnequalMap(); + LoggerContextVO vo = new LoggerContextVO("ctx", selfUnequal, BIRTH_TIME); + + assertTrue(vo.equals(vo)); + // another instance with the very same field values is not equal: the + // property maps are compared, and they never match + assertFalse(vo.equals(new LoggerContextVO("ctx", selfUnequal, BIRTH_TIME))); + } + @Test public void equalsRejectsNullAndOtherTypes() { LoggerContextVO vo = new LoggerContextVO("ctx", props("k", "v"), BIRTH_TIME); diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggingEventVOTest.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggingEventVOTest.java index 3449c22eb..7819bfeb5 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggingEventVOTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggingEventVOTest.java @@ -35,6 +35,7 @@ import org.slf4j.MarkerFactory; import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; public class LoggingEventVOTest { @@ -55,6 +56,21 @@ static List markers(Marker... markers) { return list; } + /** A list that is not even equal to itself. */ + static final class SelfUnequalList extends ArrayList { + private static final long serialVersionUID = 1L; + + @Override + public boolean equals(Object o) { + return false; + } + + @Override + public int hashCode() { + return 0; + } + } + static Map mdc(String key, String value) { Map map = new HashMap(); map.put(key, value); @@ -103,6 +119,21 @@ public void buildCopiesTheStateOfTheSourceEvent() { assertNull(vo.getThrowableProxy()); } + @Test + public void buildConvertsTheThrowableProxyToAValueObject() { + DummyThrowableProxy proxy = new DummyThrowableProxy(); + proxy.setClassName("java.lang.IllegalStateException"); + proxy.setMessage("boom"); + PubLoggingEventVO source = fullSource(); + source.throwableProxy = proxy; + + IThrowableProxy converted = LoggingEventVO.build(source).getThrowableProxy(); + + assertTrue(converted instanceof ThrowableProxyVO); + assertEquals(ThrowableProxyVO.build(proxy), converted); + assertEquals("boom", converted.getMessage()); + } + @Test public void getMdcReturnsTheMdcPropertyMap() { PubLoggingEventVO source = fullSource(); @@ -141,6 +172,20 @@ public void callerDataIsAbsentWhenTheSourceHasNone() { assertNull(vo.getCallerData()); } + @Test + public void buildDoesNotComputeCallerDataTheSourceHasNotComputedYet() { + // LoggingEvent computes its caller data lazily in getCallerData(); building + // a VO must not trigger that (LBCLASSIC-145) + LoggerContext lc = new LoggerContext(); + LoggingEvent event = new LoggingEvent(getClass().getName(), lc.getLogger("a.b.C"), Level.INFO, "msg", null, null); + + LoggingEventVO vo = LoggingEventVO.build(event); + + assertFalse(vo.hasCallerData()); + assertNull(vo.getCallerData()); + assertFalse(event.hasCallerData()); + } + @Test public void formattedMessageSubstitutesArguments() { LoggingEventVO vo = LoggingEventVO.build(fullSource()); @@ -187,6 +232,17 @@ public void equalsIsReflexive() { assertTrue(vo.equals(vo)); } + @Test + public void equalsIsTrueForTheSameInstanceWithoutComparingFields() throws Exception { + List selfUnequal = new SelfUnequalList(); + LoggingEventVO vo = fullEventWith("markers", selfUnequal); + + assertTrue(vo.equals(vo)); + // another instance with the very same field values is not equal: the + // markers are compared, and they never match + assertFalse(vo.equals(fullEventWith("markers", selfUnequal))); + } + @Test public void equalsRejectsNullAndOtherTypes() { LoggingEventVO vo = LoggingEventVO.build(fullSource()); From bc61476fd6da6986766411b9df95edc4f86ce2f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:47:47 +0000 Subject: [PATCH 05/79] refactor: drop unreachable SecurityException handlers in OptionHelper OptionHelper.getEnv() and getSystemProperties() caught a SecurityException from System.getenv(String) and System.getProperties(). Those only throw it when a SecurityManager denies access, and none can be in place where the library runs: Android never has one (System.setSecurityManager throws there). The unit tests can't install one either: JDK 18+ refuses to at runtime unless started with -Djava.security.manager=allow, and CI gates coverage on JDK 21 too. Unlike System.getProperty and System.setProperty, which delegate to the installed Properties object (and so can be made to throw by a test), these two calls give a test no way in, so the handlers could never be covered. Both methods now return the JDK call's result directly; their result is unchanged for every input reachable on Android. Tests pin that getEnv() reads the environment and getSystemProperties() returns the system properties. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../qos/logback/core/util/OptionHelper.java | 19 +++++++------------ .../logback/core/util/OptionHelperTest.java | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/logback-android/src/main/java/ch/qos/logback/core/util/OptionHelper.java b/logback-android/src/main/java/ch/qos/logback/core/util/OptionHelper.java index 811372a9a..8b0d262a0 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/util/OptionHelper.java +++ b/logback-android/src/main/java/ch/qos/logback/core/util/OptionHelper.java @@ -124,11 +124,9 @@ public static String getSystemProperty(String key, String def) { * @return value corresponding to key from the OS environment */ public static String getEnv(String key) { - try { - return System.getenv(key); - } catch (SecurityException e) { - return null; - } + // System.getenv only throws SecurityException when a SecurityManager + // denies access, and Android never has one + return System.getenv(key); } /** @@ -179,17 +177,14 @@ public static void setSystemProperty(ContextAware contextAware, String key, Stri } /** - * Very similar to {@link System#getProperties()} except that the - * {@link SecurityException} is absorbed. + * Same as {@link System#getProperties()}. * * @return the system properties */ public static Properties getSystemProperties() { - try { - return System.getProperties(); - } catch (SecurityException e) { - return new Properties(); - } + // System.getProperties only throws SecurityException when a + // SecurityManager denies access, and Android never has one + return System.getProperties(); } /** diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/OptionHelperTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/OptionHelperTest.java index 0b632433c..43fa23e53 100755 --- a/logback-android/src/test/java/ch/qos/logback/core/util/OptionHelperTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/util/OptionHelperTest.java @@ -17,6 +17,8 @@ import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.fail; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import java.util.HashMap; import java.util.Map; @@ -298,4 +300,17 @@ public void curlyBraces_LOGBACK_1101() { assertEquals(input, r); } } + + @Test + public void getEnvReadsTheEnvironment() { + for (Map.Entry entry : System.getenv().entrySet()) { + assertEquals(entry.getValue(), OptionHelper.getEnv(entry.getKey())); + } + assertNull(OptionHelper.getEnv("OPTION_HELPER_TEST_UNDEFINED_VARIABLE")); + } + + @Test + public void getSystemPropertiesReturnsTheSystemProperties() { + assertSame(System.getProperties(), OptionHelper.getSystemProperties()); + } } From deec093e444f409d0a6ce0c3fff1038a721c8f81 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:48:21 +0000 Subject: [PATCH 06/79] test: cover ch.qos.logback.core.util classes fully Brings the 20 core.util classes CI reported short of full line/branch coverage to 100%. The tests pin: - COWArrayList: every List operation, and that each modification refreshes the typed array copy. - CharSequenceToRegexMapper: rejected backslash and repeated quotes, literal characters with repeat counts. - CloseUtil: null arguments are ignored and close failures of closeables, sockets and server sockets are suppressed. - ContentTypeUtil, ContextUtil, EnvUtil, FixedDelay, PropertySetterException, LocationUtil, StringCollectionUtil: remaining overloads, null/invalid inputs and their exact results or exception messages. - Duration and FileSize: unparsable input, toString units, unbounded duration, and the IllegalStateException for a unit their pattern admits but valueOf() doesn't handle (a copy of the class initialized with a wider pattern via Mockito's static mock of Pattern.compile). - ExecutorServiceUtil: pool configuration, daemon "logback-N" threads, already-daemon threads left untouched, and shutdown interrupting tasks. - FileUtil: prefixRelativePath branches, the default AndroidContextUtil fallback, and copy() failures (reported as an error status and RolloverFailure; both streams closed even when closing fails). - InterruptUtil: masking/unmasking, and an error status when re-interrupting the current thread is denied. - Loader: resource lookup and counting, TCL/Class.forName fallbacks, and, through copies of Loader re-initialized by FreshCopyClassLoader, the logback.ignoreTCL property and the getClassLoader permission check. - OptionHelper: instantiation errors (null name, incompatible class, loading failures), ScanException wrapping, SecurityException handling of system property reads/writes (injected through a denying Properties object), the Android property fallback, toBoolean, isEmpty and extractDefaultReplacement. - StatusListenerConfigHelper: installIfAsked for plain, context-aware and life-cycle listeners, listeners rejected by the status manager, and listener classes that can't be instantiated. - StatusPrinter: null contexts, contexts without a status manager, error/ warning thresholds, status lists, and throwable line formatting. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../logback/core/util/COWArrayListTest.java | 158 +++++++++++ .../util/CharSequenceToRegexMapperTest.java | 34 +++ .../qos/logback/core/util/CloseUtilTest.java | 144 ++++++++++ .../core/util/ContentTypeUtilTest.java | 20 ++ .../logback/core/util/ContextUtilTest.java | 90 ++++++ .../qos/logback/core/util/DurationTest.java | 53 ++++ .../ch/qos/logback/core/util/EnvUtilTest.java | 8 + .../core/util/ExecutorServiceUtilTest.java | 204 +++++++++++++ .../qos/logback/core/util/FileSizeTest.java | 37 +++ .../qos/logback/core/util/FileUtilTest.java | 143 ++++++++++ .../qos/logback/core/util/FixedDelayTest.java | 41 +++ .../core/util/FreshCopyClassLoader.java | 106 +++++++ .../logback/core/util/InterruptUtilTest.java | 127 +++++++++ .../ch/qos/logback/core/util/LoaderTest.java | 268 ++++++++++++++++++ .../logback/core/util/LocationUtilTest.java | 58 ++++ .../logback/core/util/OptionHelperTest.java | 232 +++++++++++++++ .../util/PropertySetterExceptionTest.java | 51 ++++ .../util/StatusListenerConfigHelperTest.java | 138 +++++++++ .../logback/core/util/StatusPrinterTest.java | 155 +++++++++- .../core/util/StringCollectionUtilTest.java | 7 + 20 files changed, 2073 insertions(+), 1 deletion(-) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/util/CloseUtilTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/util/ContextUtilTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/util/ExecutorServiceUtilTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/util/FixedDelayTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/util/FreshCopyClassLoader.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/util/InterruptUtilTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/util/LoaderTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/util/PropertySetterExceptionTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/COWArrayListTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/COWArrayListTest.java index 61916abc9..71a6bf334 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/util/COWArrayListTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/util/COWArrayListTest.java @@ -17,6 +17,12 @@ import static org.junit.Assert.*; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.ListIterator; + import org.junit.Test; public class COWArrayListTest { @@ -46,4 +52,156 @@ public void basicToArrayTyped() { assertArrayEquals(new Integer[] { 1 }, result); } + @Test + public void sizeIsEmptyAndContainsReflectTheUnderlyingList() { + assertEquals(0, cowaList.size()); + assertTrue(cowaList.isEmpty()); + assertFalse(cowaList.contains(1)); + + cowaList.add(1); + cowaList.add(2); + + assertEquals(2, cowaList.size()); + assertFalse(cowaList.isEmpty()); + assertTrue(cowaList.contains(2)); + assertFalse(cowaList.contains(3)); + } + + @Test + public void containsAllChecksEveryElement() { + cowaList.addAll(Arrays.asList(1, 2, 3)); + assertTrue(cowaList.containsAll(Arrays.asList(1, 3))); + assertFalse(cowaList.containsAll(Arrays.asList(1, 4))); + } + + @Test + public void addAllAppendsAndRefreshesTheTypedArray() { + cowaList.add(1); + assertArrayEquals(new Integer[] { 1 }, cowaList.asTypedArray()); + + assertTrue(cowaList.addAll(Arrays.asList(2, 3))); + assertArrayEquals(new Integer[] { 1, 2, 3 }, cowaList.asTypedArray()); + + assertFalse(cowaList.addAll(Collections.emptyList())); + } + + @Test + public void addAllAtIndexInsertsAndRefreshesTheTypedArray() { + cowaList.addAll(Arrays.asList(1, 4)); + assertArrayEquals(new Integer[] { 1, 4 }, cowaList.asTypedArray()); + + assertTrue(cowaList.addAll(1, Arrays.asList(2, 3))); + assertArrayEquals(new Integer[] { 1, 2, 3, 4 }, cowaList.asTypedArray()); + } + + @Test + public void removeAllRemovesAndRefreshesTheTypedArray() { + cowaList.addAll(Arrays.asList(1, 2, 3)); + assertArrayEquals(new Integer[] { 1, 2, 3 }, cowaList.asTypedArray()); + + assertTrue(cowaList.removeAll(Arrays.asList(1, 3))); + assertArrayEquals(new Integer[] { 2 }, cowaList.asTypedArray()); + assertFalse(cowaList.removeAll(Arrays.asList(7))); + } + + @Test + public void retainAllRetainsAndRefreshesTheTypedArray() { + cowaList.addAll(Arrays.asList(1, 2, 3)); + assertArrayEquals(new Integer[] { 1, 2, 3 }, cowaList.asTypedArray()); + + assertTrue(cowaList.retainAll(Arrays.asList(2, 3))); + assertArrayEquals(new Integer[] { 2, 3 }, cowaList.asTypedArray()); + assertFalse(cowaList.retainAll(Arrays.asList(2, 3))); + } + + @Test + public void getReadsFromTheRefreshedCopy() { + cowaList.add(1); + assertEquals(Integer.valueOf(1), cowaList.get(0)); + + cowaList.add(0, 5); + assertEquals(Integer.valueOf(5), cowaList.get(0)); + assertEquals(Integer.valueOf(1), cowaList.get(1)); + } + + @Test + public void setReplacesReturnsPreviousAndRefreshesTheTypedArray() { + cowaList.addAll(Arrays.asList(1, 2)); + assertArrayEquals(new Integer[] { 1, 2 }, cowaList.asTypedArray()); + + assertEquals(Integer.valueOf(2), cowaList.set(1, 9)); + assertArrayEquals(new Integer[] { 1, 9 }, cowaList.asTypedArray()); + } + + @Test + public void addAtIndexInsertsAndRefreshesTheTypedArray() { + cowaList.addAll(Arrays.asList(1, 3)); + assertArrayEquals(new Integer[] { 1, 3 }, cowaList.asTypedArray()); + + cowaList.add(1, 2); + assertArrayEquals(new Integer[] { 1, 2, 3 }, cowaList.asTypedArray()); + } + + @Test + public void removeAtIndexReturnsElementAndRefreshesTheTypedArray() { + cowaList.addAll(Arrays.asList(1, 2, 3)); + assertArrayEquals(new Integer[] { 1, 2, 3 }, cowaList.asTypedArray()); + + assertEquals(Integer.valueOf(1), cowaList.remove(0)); + assertArrayEquals(new Integer[] { 2, 3 }, cowaList.asTypedArray()); + } + + @Test + public void indexOfAndLastIndexOfSearchTheUnderlyingList() { + cowaList.addAll(Arrays.asList(7, 8, 7)); + assertEquals(0, cowaList.indexOf(7)); + assertEquals(2, cowaList.lastIndexOf(7)); + assertEquals(-1, cowaList.indexOf(9)); + assertEquals(-1, cowaList.lastIndexOf(9)); + } + + @Test + public void iteratorsWalkTheUnderlyingList() { + cowaList.addAll(Arrays.asList(1, 2, 3)); + + Iterator it = cowaList.iterator(); + assertEquals(Integer.valueOf(1), it.next()); + + ListIterator li = cowaList.listIterator(); + assertFalse(li.hasPrevious()); + assertEquals(Integer.valueOf(1), li.next()); + + ListIterator fromIndex = cowaList.listIterator(2); + assertEquals(2, fromIndex.nextIndex()); + assertEquals(Integer.valueOf(3), fromIndex.next()); + assertFalse(fromIndex.hasNext()); + } + + @Test + public void subListViewsTheRequestedRange() { + cowaList.addAll(Arrays.asList(1, 2, 3, 4)); + List sub = cowaList.subList(1, 3); + assertEquals(Arrays.asList(2, 3), sub); + } + + @Test + public void addIfAbsentAndClearRefreshTheTypedArray() { + cowaList.addIfAbsent(1); + cowaList.addIfAbsent(1); + assertArrayEquals(new Integer[] { 1 }, cowaList.asTypedArray()); + + cowaList.clear(); + assertArrayEquals(new Integer[0], cowaList.asTypedArray()); + } + + @Test + public void removeObjectRefreshesTheTypedArray() { + cowaList.addAll(Arrays.asList(1, 2)); + assertArrayEquals(new Integer[] { 1, 2 }, cowaList.asTypedArray()); + + assertTrue(cowaList.remove(Integer.valueOf(1))); + assertArrayEquals(new Integer[] { 2 }, cowaList.asTypedArray()); + assertFalse(cowaList.remove(Integer.valueOf(1))); + } + } diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/CharSequenceToRegexMapperTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/CharSequenceToRegexMapperTest.java index 9c1c3caa6..e85f9bdf2 100755 --- a/logback-android/src/test/java/ch/qos/logback/core/util/CharSequenceToRegexMapperTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/util/CharSequenceToRegexMapperTest.java @@ -24,6 +24,7 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; public class CharSequenceToRegexMapperTest { @@ -83,5 +84,38 @@ private void checkEmptyString(String[] symbolArray, String category) { } } + private static CharSequenceState css(char c, int occurrences) { + CharSequenceState state = new CharSequenceState(c); + for (int i = 1; i < occurrences; i++) { + state.incrementOccurrences(); + } + return state; + } + + @Test + public void backslashIsRejected() { + final CharSequenceToRegexMapper mapper = new CharSequenceToRegexMapper(); + IllegalStateException e = assertThrows(IllegalStateException.class, () -> mapper.toRegex(css('\\', 1))); + assertEquals("Forward slashes are not allowed", e.getMessage()); + } + + @Test + public void singleQuoteMapsToEmptyString() { + assertEquals("", new CharSequenceToRegexMapper().toRegex(css('\'', 1))); + } + + @Test + public void repeatedSingleQuotesAreRejected() { + final CharSequenceToRegexMapper mapper = new CharSequenceToRegexMapper(); + IllegalStateException e = assertThrows(IllegalStateException.class, () -> mapper.toRegex(css('\'', 2))); + assertEquals("Too many single quotes", e.getMessage()); + } + + @Test + public void otherCharactersMapToThemselvesRepeatedByOccurrences() { + CharSequenceToRegexMapper mapper = new CharSequenceToRegexMapper(); + assertEquals("-", mapper.toRegex(css('-', 1))); + assertEquals("-{3}", mapper.toRegex(css('-', 3))); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/CloseUtilTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/CloseUtilTest.java new file mode 100644 index 000000000..c5d88e2b6 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/util/CloseUtilTest.java @@ -0,0 +1,144 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.Closeable; +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; + +import org.junit.Test; + +/** + * Unit tests for {@link CloseUtil}. + */ +public class CloseUtilTest { + + @Test + public void isInstantiable() { + // the class only has static members, but its implicit constructor is public + assertNotNull(new CloseUtil()); + } + + @Test + public void closeableNullIsIgnored() { + CloseUtil.closeQuietly((Closeable) null); + } + + @Test + public void closeableIsClosed() { + CountingCloseable closeable = new CountingCloseable(null); + CloseUtil.closeQuietly(closeable); + assertEquals(1, closeable.closeCount); + } + + @Test + public void closeableIOExceptionIsSuppressed() { + CountingCloseable closeable = new CountingCloseable(new IOException("close failed")); + CloseUtil.closeQuietly(closeable); + assertEquals(1, closeable.closeCount); + } + + @Test + public void socketNullIsIgnored() { + CloseUtil.closeQuietly((Socket) null); + } + + @Test + public void socketIsClosed() { + Socket socket = new Socket(); + CloseUtil.closeQuietly(socket); + assertTrue(socket.isClosed()); + } + + @Test + public void socketIOExceptionIsSuppressed() { + FailingCloseSocket socket = new FailingCloseSocket(); + CloseUtil.closeQuietly(socket); + assertEquals(1, socket.closeCount); + assertTrue(socket.isClosed()); + } + + @Test + public void serverSocketNullIsIgnored() { + CloseUtil.closeQuietly((ServerSocket) null); + } + + @Test + public void serverSocketIsClosed() throws IOException { + ServerSocket serverSocket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress()); + CloseUtil.closeQuietly(serverSocket); + assertTrue(serverSocket.isClosed()); + } + + @Test + public void serverSocketIOExceptionIsSuppressed() throws IOException { + FailingCloseServerSocket serverSocket = new FailingCloseServerSocket(); + CloseUtil.closeQuietly(serverSocket); + assertEquals(1, serverSocket.closeCount); + assertTrue(serverSocket.isClosed()); + } + + private static class CountingCloseable implements Closeable { + private final IOException toThrow; + int closeCount; + + CountingCloseable(IOException toThrow) { + this.toThrow = toThrow; + } + + @Override + public void close() throws IOException { + closeCount++; + if (toThrow != null) { + throw toThrow; + } + } + } + + /** Closes the real socket, then reports a failure. */ + private static class FailingCloseSocket extends Socket { + int closeCount; + + @Override + public synchronized void close() throws IOException { + closeCount++; + super.close(); + throw new IOException("close failed"); + } + } + + /** Closes the real server socket, then reports a failure. */ + private static class FailingCloseServerSocket extends ServerSocket { + int closeCount; + + FailingCloseServerSocket() throws IOException { + super(); + } + + @Override + public void close() throws IOException { + closeCount++; + super.close(); + throw new IOException("close failed"); + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/ContentTypeUtilTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/ContentTypeUtilTest.java index 8dcaa37f6..4eba2079b 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/util/ContentTypeUtilTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/util/ContentTypeUtilTest.java @@ -43,4 +43,24 @@ public void emptySubtype() { assertTrue(ContentTypeUtil.isTextual(contextType)); assertNull(ContentTypeUtil.getSubType(contextType)); } + + @Test + public void noSubtypeWithoutSlash() { + String contextType = "text"; + assertTrue(ContentTypeUtil.isTextual(contextType)); + assertNull(ContentTypeUtil.getSubType(contextType)); + } + + @Test + public void nonTextualType() { + String contextType = "application/json"; + assertFalse(ContentTypeUtil.isTextual(contextType)); + assertEquals("json", ContentTypeUtil.getSubType(contextType)); + } + + @Test + public void isInstantiable() { + // the class only has static members, but its implicit constructor is public + assertNotNull(new ContentTypeUtil()); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/ContextUtilTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/ContextUtilTest.java new file mode 100644 index 000000000..edfd3e5b7 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/util/ContextUtilTest.java @@ -0,0 +1,90 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.rolling.helper.FileNamePattern; + +/** + * Unit tests for {@link ContextUtil}. + */ +public class ContextUtilTest { + + Context context = new ContextBase(); + ContextUtil contextUtil = new ContextUtil(context); + + @Test + public void addHostNameAsPropertyPutsLocalhost() { + contextUtil.addHostNameAsProperty(); + assertEquals("localhost", context.getProperty(CoreConstants.HOSTNAME_KEY)); + } + + @Test + public void addPropertiesCopiesEveryPropertyIntoTheContext() { + Properties props = new Properties(); + props.setProperty("ctxUtilKeyA", "valueA"); + props.setProperty("ctxUtilKeyB", "valueB"); + + contextUtil.addProperties(props); + + Map expected = new HashMap(); + expected.put("ctxUtilKeyA", "valueA"); + expected.put("ctxUtilKeyB", "valueB"); + assertEquals(expected, context.getCopyOfPropertyMap()); + } + + @Test + public void addPropertiesIgnoresNull() { + contextUtil.addProperties(null); + assertEquals(new HashMap(), context.getCopyOfPropertyMap()); + } + + @Test + public void filenameCollisionMapIsReadFromTheContext() { + Map map = new HashMap(); + context.putObject(CoreConstants.FA_FILENAME_COLLISION_MAP, map); + assertSame(map, ContextUtil.getFilenameCollisionMap(context)); + } + + @Test + public void filenameCollisionMapOfNullContextIsNull() { + assertNull(ContextUtil.getFilenameCollisionMap(null)); + } + + @Test + public void filenamePatternCollisionMapIsReadFromTheContext() { + Map map = new HashMap(); + context.putObject(CoreConstants.RFA_FILENAME_PATTERN_COLLISION_MAP, map); + assertSame(map, ContextUtil.getFilenamePatternCollisionMap(context)); + } + + @Test + public void filenamePatternCollisionMapOfNullContextIsNull() { + assertNull(ContextUtil.getFilenamePatternCollisionMap(null)); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/DurationTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/DurationTest.java index 98d7fd9cd..0128d55b0 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/util/DurationTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/util/DurationTest.java @@ -16,8 +16,19 @@ package ch.qos.logback.core.util; import static junit.framework.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mockStatic; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.regex.Pattern; import org.junit.Test; +import org.mockito.MockedStatic; public class DurationTest { @@ -103,4 +114,46 @@ public void test() { assertEquals(5000*DAYS_CO, d.getMilliseconds()); } } + + @Test + public void valueOfRejectsUnparsableInput() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> Duration.valueOf("5 fortnights")); + assertEquals("String value [5 fortnights] is not in the expected format.", e.getMessage()); + } + + @Test + public void valueOfRejectsUnitAdmittedByThePatternButNotHandled() throws Exception { + // valueOf() handles every unit that its pattern admits; initialize a copy + // of Duration whose pattern also admits another unit + Pattern wider = Pattern.compile("([0-9]+)()\\s*(fortnight)s?", Pattern.CASE_INSENSITIVE); + Class duration; + try (MockedStatic patterns = mockStatic(Pattern.class, CALLS_REAL_METHODS)) { + patterns.when(() -> Pattern.compile(anyString(), eq(Pattern.CASE_INSENSITIVE))).thenReturn(wider); + duration = FreshCopyClassLoader.initializeFreshCopy(Duration.class); + } + Method valueOf = duration.getMethod("valueOf", String.class); + + InvocationTargetException e = assertThrows(InvocationTargetException.class, + () -> valueOf.invoke(null, "2 fortnights")); + + assertTrue(e.getCause() instanceof IllegalStateException); + assertEquals("Unexpected fortnight", e.getCause().getMessage()); + } + + @Test + public void unboundedIsLongMaxValue() { + assertEquals(Long.MAX_VALUE, Duration.buildUnbounded().getMilliseconds()); + } + + @Test + public void toStringUsesTheLargestFittingUnit() { + assertEquals("999 milliseconds", new Duration(999).toString()); + assertEquals("1 seconds", new Duration(1000).toString()); + assertEquals("59 seconds", new Duration(59999).toString()); + assertEquals("1 minutes", new Duration(60 * 1000).toString()); + assertEquals("59 minutes", new Duration(HOURS_CO * 1000 - 1).toString()); + assertEquals("1 hours", new Duration(HOURS_CO * 1000).toString()); + assertEquals("48 hours", new Duration(2 * DAYS_CO * 1000).toString()); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/EnvUtilTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/EnvUtilTest.java index 4c5c02f06..4bdcb18dd 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/util/EnvUtilTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/util/EnvUtilTest.java @@ -98,6 +98,14 @@ public void testJava9_1() { assertTrue(EnvUtil.isJDK7OrHigher()); } + @Test + public void unparsableVersionIsNotJDK5OrHigher() { + System.setProperty("java.version", "unknown"); + + assertFalse(EnvUtil.isJDK5()); + assertFalse(EnvUtil.isJDK7OrHigher()); + } + @Test public void testJava10() { System.setProperty("java.version", "10.xx"); diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/ExecutorServiceUtilTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/ExecutorServiceUtilTest.java new file mode 100644 index 000000000..c03994975 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/util/ExecutorServiceUtilTest.java @@ -0,0 +1,204 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.After; +import org.junit.Test; + +import ch.qos.logback.core.CoreConstants; + +/** + * Unit tests for {@link ExecutorServiceUtil}. + */ +public class ExecutorServiceUtilTest { + + private static final Callable CURRENT_THREAD = new Callable() { + @Override + public Thread call() { + return Thread.currentThread(); + } + }; + + private ExecutorService executorService; + + @After + public void tearDown() throws InterruptedException { + if (executorService != null) { + executorService.shutdownNow(); + assertTrue(executorService.awaitTermination(10, TimeUnit.SECONDS)); + } + } + + @Test + public void isInstantiable() { + // the class only has static members, but its implicit constructor is public + assertNotNull(new ExecutorServiceUtil()); + } + + @Test + public void newExecutorServiceIsConfiguredForLogbackComponents() throws Exception { + executorService = ExecutorServiceUtil.newExecutorService(); + + ThreadPoolExecutor tpe = (ThreadPoolExecutor) executorService; + assertEquals(CoreConstants.CORE_POOL_SIZE, tpe.getCorePoolSize()); + assertEquals(CoreConstants.MAX_POOL_SIZE, tpe.getMaximumPoolSize()); + assertEquals(0L, tpe.getKeepAliveTime(TimeUnit.MILLISECONDS)); + assertTrue(tpe.getQueue() instanceof SynchronousQueue); + assertSame(logbackThreadFactory(), tpe.getThreadFactory()); + + Thread worker = executorService.submit(CURRENT_THREAD).get(10, TimeUnit.SECONDS); + assertTrue(worker.isDaemon()); + assertTrue(worker.getName(), worker.getName().matches("logback-\\d+")); + } + + @Test + public void newScheduledExecutorServiceIsConfiguredForLogbackComponents() throws Exception { + ScheduledExecutorService ses = ExecutorServiceUtil.newScheduledExecutorService(); + executorService = ses; + + ScheduledThreadPoolExecutor stpe = (ScheduledThreadPoolExecutor) ses; + assertEquals(CoreConstants.SCHEDULED_EXECUTOR_POOL_SIZE, stpe.getCorePoolSize()); + assertSame(logbackThreadFactory(), stpe.getThreadFactory()); + + Thread worker = ses.schedule(CURRENT_THREAD, 0, TimeUnit.MILLISECONDS).get(10, TimeUnit.SECONDS); + assertTrue(worker.isDaemon()); + assertTrue(worker.getName(), worker.getName().matches("logback-\\d+")); + } + + @Test + public void shutdownInterruptsRunningTasks() throws Exception { + executorService = ExecutorServiceUtil.newExecutorService(); + final CountDownLatch started = new CountDownLatch(1); + final CountDownLatch never = new CountDownLatch(1); + final AtomicBoolean interrupted = new AtomicBoolean(false); + executorService.execute(new Runnable() { + @Override + public void run() { + started.countDown(); + try { + never.await(); + } catch (InterruptedException e) { + interrupted.set(true); + } + } + }); + started.await(); + + ExecutorServiceUtil.shutdown(executorService); + + assertTrue(executorService.isShutdown()); + assertTrue(executorService.awaitTermination(10, TimeUnit.SECONDS)); + assertTrue(interrupted.get()); + } + + @Test + public void threadFactoryMakesDaemonThreadsNamedSequentially() throws Exception { + ThreadFactory factory = newLogbackThreadFactory(null); + Runnable noop = new Runnable() { + @Override + public void run() { + } + }; + + Thread first = factory.newThread(noop); + Thread second = factory.newThread(noop); + + assertTrue(first.isDaemon()); + assertEquals("logback-1", first.getName()); + assertTrue(second.isDaemon()); + assertEquals("logback-2", second.getName()); + } + + @Test + public void threadFactoryLeavesTheDaemonFlagOfDaemonThreadsUntouched() throws Exception { + final CountDownLatch release = new CountDownLatch(1); + // Thread.setDaemon() throws for a live thread, so the factory must not + // call it for a thread that already is a daemon + final Thread liveDaemon = new Thread(new Runnable() { + @Override + public void run() { + try { + release.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }); + liveDaemon.setDaemon(true); + liveDaemon.start(); + try { + ThreadFactory factory = newLogbackThreadFactory(new ThreadFactory() { + @Override + public Thread newThread(Runnable r) { + return liveDaemon; + } + }); + + Thread thread = factory.newThread(null); + + assertSame(liveDaemon, thread); + assertTrue(thread.isDaemon()); + assertEquals("logback-1", thread.getName()); + } finally { + release.countDown(); + liveDaemon.join(); + } + assertFalse(liveDaemon.isAlive()); + } + + private static ThreadFactory logbackThreadFactory() throws Exception { + Field field = ExecutorServiceUtil.class.getDeclaredField("THREAD_FACTORY"); + field.setAccessible(true); + return (ThreadFactory) field.get(null); + } + + /** + * Creates another instance of the logback thread factory, with its own + * thread counter and, if {@code delegate} isn't null, with {@code delegate} + * standing in for the JDK's default thread factory. + */ + private static ThreadFactory newLogbackThreadFactory(ThreadFactory delegate) throws Exception { + Class factoryClass = logbackThreadFactory().getClass(); + Constructor constructor = factoryClass.getDeclaredConstructor(); + constructor.setAccessible(true); + ThreadFactory factory = (ThreadFactory) constructor.newInstance(); + if (delegate != null) { + Field defaultFactory = factoryClass.getDeclaredField("defaultFactory"); + defaultFactory.setAccessible(true); + defaultFactory.set(factory, delegate); + } + return factory; + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/FileSizeTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/FileSizeTest.java index a9ebc30d5..cf4912fc5 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/util/FileSizeTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/util/FileSizeTest.java @@ -16,8 +16,19 @@ package ch.qos.logback.core.util; import static junit.framework.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mockStatic; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.regex.Pattern; import org.junit.Test; +import org.mockito.MockedStatic; public class FileSizeTest{ @@ -56,6 +67,32 @@ public void testValueOf() { } + @Test + public void valueOfRejectsUnparsableInput() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> FileSize.valueOf("8 tb")); + assertEquals("String value [8 tb] is not in the expected format.", e.getMessage()); + } + + @Test + public void valueOfRejectsUnitAdmittedByThePatternButNotHandled() throws Exception { + // valueOf() handles every unit that its pattern admits; initialize a copy + // of FileSize whose pattern also admits another unit + Pattern wider = Pattern.compile("([0-9]+)\\s*(tb)s?", Pattern.CASE_INSENSITIVE); + Class fileSize; + try (MockedStatic patterns = mockStatic(Pattern.class, CALLS_REAL_METHODS)) { + patterns.when(() -> Pattern.compile(anyString(), eq(Pattern.CASE_INSENSITIVE))).thenReturn(wider); + fileSize = FreshCopyClassLoader.initializeFreshCopy(FileSize.class); + } + Method valueOf = fileSize.getMethod("valueOf", String.class); + + InvocationTargetException e = assertThrows(InvocationTargetException.class, + () -> valueOf.invoke(null, "3 tb")); + + assertTrue(e.getCause() instanceof IllegalStateException); + assertEquals("Unexpected tb", e.getCause().getMessage()); + } + @Test public void testToString() { { diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/FileUtilTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/FileUtilTest.java index a38ff9e85..99f9576c8 100755 --- a/logback-android/src/test/java/ch/qos/logback/core/util/FileUtilTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/util/FileUtilTest.java @@ -18,8 +18,20 @@ import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.Closeable; import java.io.File; +import java.io.FileNotFoundException; import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -28,12 +40,20 @@ import ch.qos.logback.core.Context; import ch.qos.logback.core.ContextBase; import ch.qos.logback.core.android.AndroidContextUtil; +import ch.qos.logback.core.rolling.RolloverFailure; +import ch.qos.logback.core.status.Status; import org.junit.After; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedConstruction; public class FileUtilTest { + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + Context context = new ContextBase(); FileUtil fileUtil = new FileUtil(context); List cleanupList = new ArrayList(); @@ -156,4 +176,127 @@ public void createParentDirReturnsFalseWhenRetryAlsoFails() throws IOException { assertFalse(FileUtil.createMissingParentDirectories(file, platform)); } + + // Issue #228: without an explicit AndroidContextUtil, a default one is + // created to request the app's external storage directories + @Test + public void createParentDirWithDefaultPlatformReturnsFalseWhenDirsCannotBeCreated() throws IOException { + File blocker = tmp.newFile("blocker3"); + File file = new File(blocker, "logs/testing.txt"); + + assertFalse(FileUtil.createMissingParentDirectories(file)); + assertFalse(file.getParentFile().exists()); + assertTrue(blocker.isFile()); + } + + @Test + public void prefixRelativePathPrependsPrefixToRelativePath() { + assertEquals("/data/app/logs/app.log", FileUtil.prefixRelativePath("/data/app", "logs/app.log")); + } + + @Test + public void prefixRelativePathKeepsAbsolutePath() { + String absolutePath = new File("logs/app.log").getAbsolutePath(); + assertEquals(absolutePath, FileUtil.prefixRelativePath("/data/app", absolutePath)); + } + + @Test + public void prefixRelativePathIgnoresNullPrefix() { + assertEquals("logs/app.log", FileUtil.prefixRelativePath(null, "logs/app.log")); + } + + @Test + public void prefixRelativePathIgnoresBlankPrefix() { + assertEquals("logs/app.log", FileUtil.prefixRelativePath(" ", "logs/app.log")); + } + + @Test + public void copyReportsFailureToOpenDestination() throws IOException { + File src = tmp.newFile("src.txt"); + String destination = new File(tmp.getRoot(), "missing-dir/dest.txt").getPath(); + + RolloverFailure failure = assertThrows(RolloverFailure.class, () -> fileUtil.copy(src.getPath(), destination)); + + String msg = "Failed to copy [" + src.getPath() + "] to [" + destination + "]"; + assertEquals(msg, failure.getMessage()); + assertFalse(new File(destination).exists()); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(1, statuses.size()); + Status status = statuses.get(0); + assertEquals(Status.ERROR, status.getLevel()); + assertEquals(msg, status.getMessage()); + assertTrue(status.getThrowable() instanceof FileNotFoundException); + } + + @Test + public void copyReportsReadFailureAndClosesBothStreams() throws IOException { + File src = tmp.newFile("src.txt"); + File destination = new File(tmp.getRoot(), "dest.txt"); + final IOException readFailure = new IOException("read failed"); + final List fileStreams = new ArrayList(); + + RolloverFailure failure; + try (MockedConstruction inputs = mockConstruction(BufferedInputStream.class, + (mock, ctx) -> { + fileStreams.add((Closeable) ctx.arguments().get(0)); + when(mock.read(any(byte[].class))).thenThrow(readFailure); + })) { + failure = assertThrows(RolloverFailure.class, () -> fileUtil.copy(src.getPath(), destination.getPath())); + + assertEquals(1, inputs.constructed().size()); + verify(inputs.constructed().get(0)).close(); + } finally { + for (Closeable c : fileStreams) { + c.close(); + } + } + + assertEquals("Failed to copy [" + src.getPath() + "] to [" + destination.getPath() + "]", failure.getMessage()); + // the destination was created, then closed (and so can be deleted) + assertTrue(destination.isFile()); + assertEquals(0L, destination.length()); + assertTrue(destination.delete()); + assertSame(readFailure, context.getStatusManager().getCopyOfStatusList().get(0).getThrowable()); + } + + @Test + public void copyReportsReadFailureAndClosesBothStreamsEvenIfClosingFails() throws IOException { + File src = tmp.newFile("src.txt"); + String destination = new File(tmp.getRoot(), "dest.txt").getPath(); + final IOException readFailure = new IOException("read failed"); + // the streams handed to the (mocked) buffered streams, closed by this test + final List fileStreams = new ArrayList(); + + RolloverFailure failure; + try (MockedConstruction inputs = mockConstruction(BufferedInputStream.class, + (mock, ctx) -> { + fileStreams.add((Closeable) ctx.arguments().get(0)); + when(mock.read(any(byte[].class))).thenThrow(readFailure); + doThrow(new IOException("close failed")).when(mock).close(); + }); + MockedConstruction outputs = mockConstruction(BufferedOutputStream.class, + (mock, ctx) -> { + fileStreams.add((Closeable) ctx.arguments().get(0)); + doThrow(new IOException("close failed")).when(mock).close(); + })) { + failure = assertThrows(RolloverFailure.class, () -> fileUtil.copy(src.getPath(), destination)); + + assertEquals(1, inputs.constructed().size()); + assertEquals(1, outputs.constructed().size()); + verify(inputs.constructed().get(0)).close(); + verify(outputs.constructed().get(0)).close(); + } finally { + for (Closeable c : fileStreams) { + c.close(); + } + } + + String msg = "Failed to copy [" + src.getPath() + "] to [" + destination + "]"; + assertEquals(msg, failure.getMessage()); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(1, statuses.size()); + assertEquals(msg, statuses.get(0).getMessage()); + // the close failures don't mask the original one + assertSame(readFailure, statuses.get(0).getThrowable()); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/FixedDelayTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/FixedDelayTest.java new file mode 100644 index 000000000..de13d6ef9 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/util/FixedDelayTest.java @@ -0,0 +1,41 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.util; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +/** + * Unit tests for {@link FixedDelay}. + */ +public class FixedDelayTest { + + @Test + public void initialDelayIsFollowedBySubsequentDelays() { + FixedDelay delay = new FixedDelay(10L, 20L); + assertEquals(10L, delay.nextDelay()); + assertEquals(20L, delay.nextDelay()); + assertEquals(20L, delay.nextDelay()); + } + + @Test + public void singleDelayIsUsedForEveryDelay() { + FixedDelay delay = new FixedDelay(30); + assertEquals(30L, delay.nextDelay()); + assertEquals(30L, delay.nextDelay()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/FreshCopyClassLoader.java b/logback-android/src/test/java/ch/qos/logback/core/util/FreshCopyClassLoader.java new file mode 100644 index 000000000..02d40a587 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/util/FreshCopyClassLoader.java @@ -0,0 +1,106 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.util; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.ProtectionDomain; + +/** + * Defines its own copy of a class, and of the class's nested classes, from the + * same class files that the original was defined from, so that the copy's + * static initializer runs again (e.g. with other system properties, or within + * another protection domain). Being defined from the same bytes, the copy's + * code coverage is recorded as the original's. Every other class is loaded by + * the parent class loader, so the copy shares them with the tests. + * + *

Used by the tests of the {@code ch.qos.logback.core.util} classes whose + * static state can't otherwise be set up.

+ */ +final class FreshCopyClassLoader extends ClassLoader { + + private final String className; + private final ProtectionDomain domain; + + private FreshCopyClassLoader(Class clazz, ProtectionDomain domain) { + super(clazz.getClassLoader()); + this.className = clazz.getName(); + this.domain = domain; + } + + /** + * Defines and initializes a copy of {@code clazz} within the original's + * protection domain. + * + * @param clazz the class to copy + * @return the initialized copy + */ + static Class initializeFreshCopy(Class clazz) throws ClassNotFoundException { + return initializeFreshCopy(clazz, clazz.getProtectionDomain()); + } + + /** + * Defines and initializes a copy of {@code clazz} within {@code domain}. + * + * @param clazz the class to copy + * @param domain the protection domain of the copy and its nested classes + * @return the initialized copy + */ + static Class initializeFreshCopy(Class clazz, ProtectionDomain domain) throws ClassNotFoundException { + Class copy = Class.forName(clazz.getName(), true, new FreshCopyClassLoader(clazz, domain)); + if (copy == clazz) { + throw new IllegalStateException("not a copy: " + clazz); + } + return copy; + } + + @Override + protected synchronized Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!name.equals(className) && !name.startsWith(className + "$")) { + return super.loadClass(name, resolve); + } + Class c = findLoadedClass(name); + if (c == null) { + byte[] bytes = readClassFile(name); + c = defineClass(name, bytes, 0, bytes.length, domain); + } + if (resolve) { + resolveClass(c); + } + return c; + } + + private byte[] readClassFile(String name) throws ClassNotFoundException { + InputStream in = getParent().getResourceAsStream(name.replace('.', '/') + ".class"); + if (in == null) { + throw new ClassNotFoundException(name); + } + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + } + return out.toByteArray(); + } catch (IOException e) { + throw new ClassNotFoundException(name, e); + } finally { + CloseUtil.closeQuietly(in); + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/InterruptUtilTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/InterruptUtilTest.java new file mode 100644 index 000000000..a5c6d4576 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/util/InterruptUtilTest.java @@ -0,0 +1,127 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.List; + +import org.junit.After; +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.status.Status; + +/** + * Unit tests for {@link InterruptUtil}. + */ +public class InterruptUtilTest { + + Context context = new ContextBase(); + + @After + public void tearDown() { + // never leak an interrupt into the next test + Thread.interrupted(); + } + + @Test + public void maskAndUnmaskOfPreviouslyInterruptedThread() { + Thread.currentThread().interrupt(); + InterruptUtil interruptUtil = new InterruptUtil(context); + + interruptUtil.maskInterruptFlag(); + assertFalse(Thread.currentThread().isInterrupted()); + + interruptUtil.unmaskInterruptFlag(); + assertTrue(Thread.currentThread().isInterrupted()); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } + + @Test + public void maskAndUnmaskOfNonInterruptedThreadDoNothing() { + InterruptUtil interruptUtil = new InterruptUtil(context); + + // an interrupt arriving after construction isn't masked + Thread.currentThread().interrupt(); + interruptUtil.maskInterruptFlag(); + assertTrue(Thread.interrupted()); + + // and none is restored + interruptUtil.unmaskInterruptFlag(); + assertFalse(Thread.currentThread().isInterrupted()); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } + + @Test + public void unmaskReportsFailureToReinterrupt() throws InterruptedException { + DeniedSelfInterruptThread thread = new DeniedSelfInterruptThread(context); + thread.start(); + thread.join(); + + assertNull(thread.failure); + assertFalse("interrupt flag should not have been restored", thread.interruptedAfterUnmask); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(1, statuses.size()); + Status status = statuses.get(0); + assertEquals(Status.ERROR, status.getLevel()); + assertEquals("Failed to intrreupt current thread", status.getMessage()); + assertSame(thread.denial, status.getThrowable()); + } + + /** + * A thread that interrupts itself before using {@link InterruptUtil}, and + * then refuses to be interrupted with a {@link SecurityException}. + */ + private static class DeniedSelfInterruptThread extends Thread { + private final Context context; + final SecurityException denial = new SecurityException("interrupt denied"); + private volatile boolean denyInterrupts; + volatile boolean interruptedAfterUnmask; + volatile Throwable failure; + + DeniedSelfInterruptThread(Context context) { + this.context = context; + } + + @Override + public void interrupt() { + if (denyInterrupts) { + throw denial; + } + super.interrupt(); + } + + @Override + public void run() { + try { + interrupt(); + InterruptUtil interruptUtil = new InterruptUtil(context); + interruptUtil.maskInterruptFlag(); + denyInterrupts = true; + interruptUtil.unmaskInterruptFlag(); + interruptedAfterUnmask = isInterrupted(); + } catch (Throwable t) { + failure = t; + } + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/LoaderTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/LoaderTest.java new file mode 100644 index 000000000..2bc70b4e4 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/util/LoaderTest.java @@ -0,0 +1,268 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.URL; +import java.security.AllPermission; +import java.security.Permissions; +import java.security.ProtectionDomain; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.junit.After; +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; + +/** + * Unit tests for {@link Loader}. + */ +public class LoaderTest { + + private final Thread thread = Thread.currentThread(); + private final ClassLoader originalTCL = thread.getContextClassLoader(); + private final String originalIgnoreTCL = System.getProperty(Loader.IGNORE_TCL_PROPERTY_NAME); + + @After + public void tearDown() { + thread.setContextClassLoader(originalTCL); + if (originalIgnoreTCL == null) { + System.clearProperty(Loader.IGNORE_TCL_PROPERTY_NAME); + } else { + System.setProperty(Loader.IGNORE_TCL_PROPERTY_NAME, originalIgnoreTCL); + } + } + + @Test + public void isInstantiable() { + // the class only has static members, but its implicit constructor is public + assertNotNull(new Loader()); + } + + @Test + public void resourceOccurrencesAreCountedOncePerUrl() throws IOException { + URL a = new URL("file:/logback/a.xml"); + URL b = new URL("file:/logback/b.xml"); + ClassLoader cl = new ResourcesClassLoader(Arrays.asList(a, b, a)); + + Set urls = Loader.getResourceOccurrenceCount("logback.xml", cl); + + assertEquals(new HashSet(Arrays.asList(a, b)), urls); + } + + @Test + public void missingResourceHasNoOccurrences() throws IOException { + ClassLoader cl = new ResourcesClassLoader(Collections.emptyList()); + assertTrue(Loader.getResourceOccurrenceCount("logback.xml", cl).isEmpty()); + } + + @Test + public void getResourceUsesTheGivenClassLoader() { + URL url = Loader.getResource("util/testResource.txt", getClass().getClassLoader()); + assertNotNull(url); + assertTrue(url.toString(), url.toString().endsWith("util/testResource.txt")); + } + + @Test + public void getResourceReturnsNullIfTheClassLoaderFails() { + ClassLoader failing = new ClassLoader(null) { + @Override + public URL getResource(String name) { + throw new IllegalStateException("broken class loader"); + } + }; + assertNull(Loader.getResource("util/testResource.txt", failing)); + } + + @Test + public void getTCLReturnsTheContextClassLoader() { + ClassLoader tcl = new ClassLoader(null) { + }; + thread.setContextClassLoader(tcl); + assertSame(tcl, Loader.getTCL()); + } + + @Test + public void loadClassWithContextUsesTheClassLoaderOfTheContext() throws ClassNotFoundException { + Context context = new ContextBase(); + assertSame(FileSize.class, Loader.loadClass(FileSize.class.getName(), context)); + } + + @Test + public void classLoaderOfNullObjectIsRejected() { + NullPointerException e = assertThrows(NullPointerException.class, () -> Loader.getClassLoaderOfObject(null)); + assertEquals("Argument cannot be null", e.getMessage()); + } + + @Test + public void classLoaderOfObjectIsTheClassLoaderOfItsClass() { + assertSame(LoaderTest.class.getClassLoader(), Loader.getClassLoaderOfObject(this)); + } + + @Test + public void classLoaderOfBootstrapClassIsTheSystemClassLoader() { + assertNull(String.class.getClassLoader()); + assertSame(ClassLoader.getSystemClassLoader(), Loader.getClassLoaderOfClass(String.class)); + } + + @Test + public void loadClassPrefersTheContextClassLoader() throws ClassNotFoundException { + thread.setContextClassLoader(new ClassLoader(null) { + @Override + public Class loadClass(String name) throws ClassNotFoundException { + if ("only.in.Tcl".equals(name)) { + return FileSize.class; + } + throw new ClassNotFoundException(name); + } + }); + assertSame(FileSize.class, Loader.loadClass("only.in.Tcl")); + } + + @Test + public void loadClassFallsBackToClassForNameWithoutContextClassLoader() throws ClassNotFoundException { + thread.setContextClassLoader(null); + assertSame(FileSize.class, Loader.loadClass(FileSize.class.getName())); + } + + @Test + public void loadClassFallsBackToClassForNameIfTheContextClassLoaderFails() throws ClassNotFoundException { + RecordingClassLoader tcl = new RecordingClassLoader(); + thread.setContextClassLoader(tcl); + assertSame(FileSize.class, Loader.loadClass(FileSize.class.getName())); + assertEquals(Collections.singletonList(FileSize.class.getName()), tcl.requested); + } + + @Test + public void loadClassThrowsForUnknownClass() { + thread.setContextClassLoader(new RecordingClassLoader()); + assertThrows(ClassNotFoundException.class, () -> Loader.loadClass("no.such.Clazz")); + } + + @Test + public void ignoreTCLPropertyMakesLoadClassSkipTheContextClassLoader() throws Exception { + System.setProperty(Loader.IGNORE_TCL_PROPERTY_NAME, "true"); + Class loader = initializeFreshLoader(domainWith(new Permissions())); + assertTrue(getStaticBoolean(loader, "ignoreTCL")); + + RecordingClassLoader tcl = new RecordingClassLoader(); + thread.setContextClassLoader(tcl); + Method loadClass = loader.getMethod("loadClass", String.class); + + assertSame(FileSize.class, loadClass.invoke(null, FileSize.class.getName())); + assertTrue(tcl.requested.isEmpty()); + } + + @Test + public void ignoreTCLPropertyFalseKeepsLoadClassOnTheContextClassLoader() throws Exception { + System.setProperty(Loader.IGNORE_TCL_PROPERTY_NAME, "false"); + Class loader = initializeFreshLoader(domainWith(new Permissions())); + assertFalse(getStaticBoolean(loader, "ignoreTCL")); + + RecordingClassLoader tcl = new RecordingClassLoader(); + thread.setContextClassLoader(tcl); + Method loadClass = loader.getMethod("loadClass", String.class); + + assertSame(FileSize.class, loadClass.invoke(null, FileSize.class.getName())); + assertEquals(Collections.singletonList(FileSize.class.getName()), tcl.requested); + } + + @Test + public void classLoaderAsPrivilegedIsAvailableWithGetClassLoaderPermission() throws Exception { + Permissions all = new Permissions(); + all.add(new AllPermission()); + Class loader = initializeFreshLoader(domainWith(all)); + assertTrue(getStaticBoolean(loader, "HAS_GET_CLASS_LOADER_PERMISSION")); + + Method getClassLoaderAsPrivileged = loader.getMethod("getClassLoaderAsPrivileged", Class.class); + assertSame(LoaderTest.class.getClassLoader(), getClassLoaderAsPrivileged.invoke(null, LoaderTest.class)); + } + + @Test + public void classLoaderAsPrivilegedIsNullWithoutGetClassLoaderPermission() throws Exception { + Class loader = initializeFreshLoader(domainWith(new Permissions())); + assertFalse(getStaticBoolean(loader, "HAS_GET_CLASS_LOADER_PERMISSION")); + + Method getClassLoaderAsPrivileged = loader.getMethod("getClassLoaderAsPrivileged", Class.class); + assertNull(getClassLoaderAsPrivileged.invoke(null, LoaderTest.class)); + } + + private static ProtectionDomain domainWith(Permissions permissions) { + return new ProtectionDomain(Loader.class.getProtectionDomain().getCodeSource(), permissions); + } + + /** + * Initializes another copy of {@link Loader}, defined from the same class + * file within the given protection domain, so that its static initializer + * runs again. + */ + private static Class initializeFreshLoader(ProtectionDomain domain) throws ClassNotFoundException { + return FreshCopyClassLoader.initializeFreshCopy(Loader.class, domain); + } + + private static boolean getStaticBoolean(Class clazz, String name) throws Exception { + Field field = clazz.getDeclaredField(name); + field.setAccessible(true); + return field.getBoolean(null); + } + + /** Returns the given URLs for every resource. */ + private static class ResourcesClassLoader extends ClassLoader { + private final List urls; + + ResourcesClassLoader(List urls) { + super(null); + this.urls = urls; + } + + @Override + public Enumeration getResources(String name) { + return Collections.enumeration(urls); + } + } + + /** Records the requested class names, and fails to load any of them. */ + private static class RecordingClassLoader extends ClassLoader { + final List requested = new ArrayList(); + + RecordingClassLoader() { + super(null); + } + + @Override + public Class loadClass(String name) throws ClassNotFoundException { + requested.add(name); + throw new ClassNotFoundException(name); + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/LocationUtilTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/LocationUtilTest.java index 8baa0eb10..75571d8f4 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/util/LocationUtilTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/util/LocationUtilTest.java @@ -16,9 +16,13 @@ package ch.qos.logback.core.util; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; import java.io.BufferedReader; import java.io.File; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; @@ -26,7 +30,9 @@ import java.net.MalformedURLException; import java.net.URL; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import ch.qos.logback.core.util.LocationUtil; @@ -37,6 +43,9 @@ */ public class LocationUtilTest { + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + private static final String TEST_CLASSPATH_RESOURCE = "util/testResource.txt"; private static final String TEST_PATTERN = "TEST RESOURCE"; @@ -81,6 +90,55 @@ public void testFileUrl() throws Exception { validateResource(url); } + @Test + public void testExplicitFileUrl() throws Exception { + File file = tmp.newFile("testResource.txt"); + PrintWriter writer = new PrintWriter(file, "UTF-8"); + writer.println(TEST_PATTERN); + writer.close(); + String location = file.toURI().toURL().toString(); + + URL url = LocationUtil.urlForResource(location); + + assertEquals(location, url.toString()); + validateResource(url); + } + + @Test + public void testUnknownUrlScheme() { + MalformedURLException e = assertThrows(MalformedURLException.class, + () -> LocationUtil.urlForResource("nosuchscheme:resource")); + assertTrue(e.getMessage(), e.getMessage().contains("nosuchscheme")); + } + + @Test + public void testMissingImplicitClasspathResource() { + FileNotFoundException e = assertThrows(FileNotFoundException.class, + () -> LocationUtil.urlForResource("util/noSuchResource.txt")); + assertEquals("util/noSuchResource.txt", e.getMessage()); + } + + @Test + public void testMissingExplicitClasspathResource() { + String location = LocationUtil.CLASSPATH_SCHEME + "util/noSuchResource.txt"; + FileNotFoundException e = assertThrows(FileNotFoundException.class, + () -> LocationUtil.urlForResource(location)); + assertEquals(location, e.getMessage()); + } + + @Test + public void testNullLocation() { + NullPointerException e = assertThrows(NullPointerException.class, + () -> LocationUtil.urlForResource(null)); + assertEquals("location is required", e.getMessage()); + } + + @Test + public void isInstantiable() { + // the class only has static members, but its implicit constructor is public + assertNotNull(new LocationUtil()); + } + private void validateResource(URL url) throws IOException { InputStream inputStream = url.openStream(); try { diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/OptionHelperTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/OptionHelperTest.java index 43fa23e53..2a8e85297 100755 --- a/logback-android/src/test/java/ch/qos/logback/core/util/OptionHelperTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/util/OptionHelperTest.java @@ -17,20 +17,35 @@ import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.fail; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Properties; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; +import org.mockito.MockedStatic; import ch.qos.logback.core.Context; import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.android.SystemPropertiesProxy; import ch.qos.logback.core.joran.spi.JoranException; +import ch.qos.logback.core.spi.ContextAwareBase; +import ch.qos.logback.core.spi.ScanException; +import ch.qos.logback.core.status.Status; public class OptionHelperTest { @@ -301,6 +316,96 @@ public void curlyBraces_LOGBACK_1101() { } } + @Test + public void substVarsWrapsScanExceptions() { + String input = "${a:-b:-c}"; + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> OptionHelper.substVars(input, context)); + assertEquals("Failed to parse input [" + input + "]", e.getMessage()); + assertTrue(e.getCause() instanceof ScanException); + } + + @Test + public void isInstantiable() { + // the class only has static members, but its implicit constructor is public + assertNotNull(new OptionHelper()); + } + + @Test + public void instantiateByClassNameUsesTheNoArgConstructor() throws Exception { + Object o = OptionHelper.instantiateByClassName(ContextBase.class.getName(), Context.class, context); + assertTrue(o instanceof ContextBase); + } + + @Test + public void instantiateByClassNameAndParameterUsesTheMatchingConstructor() throws Exception { + Object o = OptionHelper.instantiateByClassNameAndParameter(FileSize.class.getName(), FileSize.class, + getClass().getClassLoader(), long.class, 42L); + assertEquals(42L, ((FileSize) o).getSize()); + } + + @Test + public void instantiateByClassNameRejectsNullClassName() { + assertThrows(NullPointerException.class, + () -> OptionHelper.instantiateByClassName(null, Object.class, getClass().getClassLoader())); + } + + @Test + public void instantiateByClassNameRejectsIncompatibleClass() { + IncompatibleClassException e = assertThrows(IncompatibleClassException.class, + () -> OptionHelper.instantiateByClassName(FileSize.class.getName(), Context.class, context)); + assertSame(Context.class, e.requestedClass); + assertSame(FileSize.class, e.obtainedClass); + } + + @Test + public void instantiateByClassNameWrapsLoadingFailures() { + DynamicClassLoadingException e = assertThrows(DynamicClassLoadingException.class, + () -> OptionHelper.instantiateByClassName("no.such.Clazz", Object.class, context)); + assertEquals("Failed to instantiate type no.such.Clazz", e.getMessage()); + assertTrue(e.getCause() instanceof ClassNotFoundException); + } + + static final String DENIED_KEY = "optionHelperTest.denied"; + + @Test + public void getSystemPropertyWithDefaultReturnsDefaultWhenAccessIsDenied() { + withDeniedSystemProperty(() -> assertEquals("def", OptionHelper.getSystemProperty(DENIED_KEY, "def"))); + } + + @Test + public void getSystemPropertyWithDefaultReturnsValueOrDefault() { + System.setProperty(DENIED_KEY, "value"); + try { + assertEquals("value", OptionHelper.getSystemProperty(DENIED_KEY, "def")); + } finally { + System.clearProperty(DENIED_KEY); + } + assertEquals("def", OptionHelper.getSystemProperty(DENIED_KEY, "def")); + } + + @Test + public void getSystemPropertyReturnsNullWhenAccessIsDenied() { + withDeniedSystemProperty(() -> assertNull(OptionHelper.getSystemProperty(DENIED_KEY))); + } + + @Test + public void getSystemPropertyPrefersSystemPropertyOverAndroidProperty() { + SystemPropertiesProxy proxy = mock(SystemPropertiesProxy.class); + when(proxy.get(DENIED_KEY, null)).thenReturn("android"); + try (MockedStatic mocked = mockStatic(SystemPropertiesProxy.class)) { + mocked.when(SystemPropertiesProxy::getInstance).thenReturn(proxy); + + assertEquals("android", OptionHelper.getSystemProperty(DENIED_KEY)); + System.setProperty(DENIED_KEY, "system"); + try { + assertEquals("system", OptionHelper.getSystemProperty(DENIED_KEY)); + } finally { + System.clearProperty(DENIED_KEY); + } + } + } + @Test public void getEnvReadsTheEnvironment() { for (Map.Entry entry : System.getenv().entrySet()) { @@ -313,4 +418,131 @@ public void getEnvReadsTheEnvironment() { public void getSystemPropertiesReturnsTheSystemProperties() { assertSame(System.getProperties(), OptionHelper.getSystemProperties()); } + + @Test + public void getAndroidSystemPropertyReturnsNullForRejectedKey() { + SystemPropertiesProxy proxy = mock(SystemPropertiesProxy.class); + when(proxy.get(DENIED_KEY, null)).thenThrow(new IllegalArgumentException("key too long")); + try (MockedStatic mocked = mockStatic(SystemPropertiesProxy.class)) { + mocked.when(SystemPropertiesProxy::getInstance).thenReturn(proxy); + + assertNull(OptionHelper.getAndroidSystemProperty(DENIED_KEY)); + } + } + + @Test + public void setSystemPropertiesSetsEveryProperty() { + Properties props = new Properties(); + props.setProperty("optionHelperTest.a", "1"); + props.setProperty("optionHelperTest.b", "2"); + ContextAwareBase contextAware = new ContextAwareBase(); + contextAware.setContext(context); + try { + OptionHelper.setSystemProperties(contextAware, props); + + assertEquals("1", System.getProperty("optionHelperTest.a")); + assertEquals("2", System.getProperty("optionHelperTest.b")); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } finally { + System.clearProperty("optionHelperTest.a"); + System.clearProperty("optionHelperTest.b"); + } + } + + @Test + public void setSystemPropertyReportsDeniedAccess() { + final ContextAwareBase contextAware = new ContextAwareBase(); + contextAware.setContext(context); + + withDeniedSystemProperty(() -> OptionHelper.setSystemProperty(contextAware, DENIED_KEY, "value")); + + assertNull(System.getProperty(DENIED_KEY)); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(1, statuses.size()); + assertEquals(Status.ERROR, statuses.get(0).getLevel()); + assertEquals("Failed to set system property [" + DENIED_KEY + "]", statuses.get(0).getMessage()); + assertTrue(statuses.get(0).getThrowable() instanceof SecurityException); + } + + @Test + public void extractDefaultReplacementSplitsKeyAndDefaultValue() { + assertArrayEquals(new String[] { "key", "def" }, OptionHelper.extractDefaultReplacement("key:-def")); + assertArrayEquals(new String[] { "key", "" }, OptionHelper.extractDefaultReplacement("key:-")); + assertArrayEquals(new String[] { "key", null }, OptionHelper.extractDefaultReplacement("key")); + assertArrayEquals(new String[] { null, null }, OptionHelper.extractDefaultReplacement(null)); + } + + @Test + public void toBooleanParsesTrueAndFalseIgnoringCaseAndWhitespace() { + assertTrue(OptionHelper.toBoolean("true", false)); + assertTrue(OptionHelper.toBoolean(" TRUE ", false)); + assertFalse(OptionHelper.toBoolean("false", true)); + assertFalse(OptionHelper.toBoolean(" False ", true)); + } + + @Test + public void toBooleanFallsBackToDefault() { + assertTrue(OptionHelper.toBoolean(null, true)); + assertFalse(OptionHelper.toBoolean(null, false)); + assertTrue(OptionHelper.toBoolean("yes", true)); + assertFalse(OptionHelper.toBoolean("yes", false)); + } + + @Test + public void isEmptyIsTrueOnlyForNullOrEmptyString() { + assertTrue(OptionHelper.isEmpty(null)); + assertTrue(OptionHelper.isEmpty("")); + assertFalse(OptionHelper.isEmpty(" ")); + assertFalse(OptionHelper.isEmpty("a")); + } + + /** + * Runs {@code r} with system properties that throw a + * {@link SecurityException} on any access to {@link #DENIED_KEY}, like a + * security manager denying that access would. + */ + private static void withDeniedSystemProperty(Runnable r) { + Properties original = System.getProperties(); + System.setProperties(new DenyingProperties(original, DENIED_KEY)); + try { + r.run(); + } finally { + System.setProperties(original); + } + } + + /** A copy of other properties that denies access to one key. */ + private static class DenyingProperties extends Properties { + private static final long serialVersionUID = 1L; + private final String deniedKey; + + DenyingProperties(Properties source, String deniedKey) { + this.deniedKey = deniedKey; + putAll(source); + } + + @Override + public String getProperty(String key) { + check(key); + return super.getProperty(key); + } + + @Override + public String getProperty(String key, String defaultValue) { + check(key); + return super.getProperty(key, defaultValue); + } + + @Override + public synchronized Object setProperty(String key, String value) { + check(key); + return super.setProperty(key, value); + } + + private void check(String key) { + if (deniedKey.equals(key)) { + throw new SecurityException("access to [" + key + "] denied"); + } + } + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/PropertySetterExceptionTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/PropertySetterExceptionTest.java new file mode 100644 index 000000000..aee61c7ae --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/util/PropertySetterExceptionTest.java @@ -0,0 +1,51 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +import org.junit.Test; + +/** + * Unit tests for {@link PropertySetterException}. + */ +public class PropertySetterExceptionTest { + + @Test + public void messageOnly() { + PropertySetterException e = new PropertySetterException("bad property"); + assertEquals("bad property", e.getMessage()); + assertNull(e.getCause()); + } + + @Test + public void rootCauseOnly() { + Throwable cause = new IllegalStateException("root"); + PropertySetterException e = new PropertySetterException(cause); + assertSame(cause, e.getCause()); + assertEquals(cause.toString(), e.getMessage()); + } + + @Test + public void messageAndCause() { + Throwable cause = new IllegalStateException("root"); + PropertySetterException e = new PropertySetterException("bad property", cause); + assertEquals("bad property", e.getMessage()); + assertSame(cause, e.getCause()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/StatusListenerConfigHelperTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/StatusListenerConfigHelperTest.java index 29421ecdb..c490c30c3 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/util/StatusListenerConfigHelperTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/util/StatusListenerConfigHelperTest.java @@ -17,6 +17,9 @@ import static org.junit.Assert.*; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.util.ArrayList; import java.util.List; import org.junit.After; @@ -25,7 +28,11 @@ import ch.qos.logback.core.Context; import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.spi.ContextAwareBase; +import ch.qos.logback.core.spi.LifeCycle; import ch.qos.logback.core.status.OnConsoleStatusListener; +import ch.qos.logback.core.status.Status; import ch.qos.logback.core.status.StatusListener; import ch.qos.logback.core.status.StatusManager; @@ -33,13 +40,23 @@ public class StatusListenerConfigHelperTest { Context context = new ContextBase(); StatusManager sm = context.getStatusManager(); + String savedListenerClass; @Before public void setUp() throws Exception { + savedListenerClass = System.getProperty(CoreConstants.STATUS_LISTENER_CLASS); + System.clearProperty(CoreConstants.STATUS_LISTENER_CLASS); } @After public void tearDown() throws Exception { + if (savedListenerClass == null) { + System.clearProperty(CoreConstants.STATUS_LISTENER_CLASS); + } else { + System.setProperty(CoreConstants.STATUS_LISTENER_CLASS, savedListenerClass); + } + LifeCycleListener.INSTANCES.clear(); + QuietConsoleListener.INSTANCES.clear(); } @Test @@ -63,4 +80,125 @@ public void addOnConsoleListenerInstanceShouldNotStartSecondListener() { } } + @Test + public void isInstantiable() { + // the class only has static members, but its implicit constructor is public + assertNotNull(new StatusListenerConfigHelper()); + } + + @Test + public void installIfAskedDoesNothingWithoutProperty() { + StatusListenerConfigHelper.installIfAsked(context); + assertTrue(sm.getCopyOfStatusListenerList().isEmpty()); + } + + @Test + public void installIfAskedAddsPlainListener() { + System.setProperty(CoreConstants.STATUS_LISTENER_CLASS, PlainListener.class.getName()); + + StatusListenerConfigHelper.installIfAsked(context); + + List listeners = sm.getCopyOfStatusListenerList(); + assertEquals(1, listeners.size()); + assertTrue(listeners.get(0) instanceof PlainListener); + } + + @Test + public void installIfAskedSetsContextAndStartsLifeCycleListener() { + System.setProperty(CoreConstants.STATUS_LISTENER_CLASS, LifeCycleListener.class.getName()); + + StatusListenerConfigHelper.installIfAsked(context); + + assertEquals(1, LifeCycleListener.INSTANCES.size()); + LifeCycleListener listener = LifeCycleListener.INSTANCES.get(0); + assertSame(context, listener.getContext()); + assertTrue(listener.isStarted()); + List listeners = sm.getCopyOfStatusListenerList(); + assertEquals(1, listeners.size()); + assertSame(listener, listeners.get(0)); + } + + @Test + public void installIfAskedDoesNotStartListenerRejectedByStatusManager() { + System.setProperty(CoreConstants.STATUS_LISTENER_CLASS, QuietConsoleListener.class.getName()); + + StatusListenerConfigHelper.installIfAsked(context); + StatusListenerConfigHelper.installIfAsked(context); + + // the status manager keeps a single console listener per class + assertEquals(2, QuietConsoleListener.INSTANCES.size()); + assertTrue(QuietConsoleListener.INSTANCES.get(0).isStarted()); + assertFalse(QuietConsoleListener.INSTANCES.get(1).isStarted()); + List listeners = sm.getCopyOfStatusListenerList(); + assertEquals(1, listeners.size()); + assertSame(QuietConsoleListener.INSTANCES.get(0), listeners.get(0)); + } + + @Test + public void installIfAskedPrintsFailureToCreateListener() { + System.setProperty(CoreConstants.STATUS_LISTENER_CLASS, "no.such.StatusListener"); + ByteArrayOutputStream err = new ByteArrayOutputStream(); + PrintStream originalErr = System.err; + System.setErr(new PrintStream(err, true)); + try { + StatusListenerConfigHelper.installIfAsked(context); + } finally { + System.setErr(originalErr); + } + + assertTrue(sm.getCopyOfStatusListenerList().isEmpty()); + String printed = err.toString(); + assertTrue(printed, printed.contains(DynamicClassLoadingException.class.getName() + + ": Failed to instantiate type no.such.StatusListener")); + } + + /** A status listener that is neither context-aware nor a life cycle. */ + public static class PlainListener implements StatusListener { + @Override + public void addStatusEvent(Status status) { + } + } + + /** A context-aware status listener with a life cycle. */ + public static class LifeCycleListener extends ContextAwareBase implements StatusListener, LifeCycle { + static final List INSTANCES = new ArrayList(); + private boolean started; + + public LifeCycleListener() { + INSTANCES.add(this); + } + + @Override + public void addStatusEvent(Status status) { + } + + @Override + public void start() { + started = true; + } + + @Override + public void stop() { + started = false; + } + + @Override + public boolean isStarted() { + return started; + } + } + + /** A console status listener that prints nowhere. */ + public static class QuietConsoleListener extends OnConsoleStatusListener { + static final List INSTANCES = new ArrayList(); + + public QuietConsoleListener() { + INSTANCES.add(this); + } + + @Override + protected PrintStream getPrintStream() { + return new PrintStream(new ByteArrayOutputStream()); + } + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/StatusPrinterTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/StatusPrinterTest.java index d4332fe17..446ec4a6f 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/util/StatusPrinterTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/util/StatusPrinterTest.java @@ -16,19 +16,27 @@ package ch.qos.logback.core.util; import static junit.framework.Assert.*; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mockStatic; import java.io.ByteArrayOutputStream; import java.io.PrintStream; +import java.util.ArrayList; +import java.util.List; import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.mockito.MockedStatic; import ch.qos.logback.core.Context; import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.helpers.ThrowableToStringArray; import ch.qos.logback.core.status.ErrorStatus; import ch.qos.logback.core.status.InfoStatus; import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusManager; import ch.qos.logback.core.status.WarnStatus; public class StatusPrinterTest { @@ -118,5 +126,150 @@ public void testWithException() { assertTrue(result.contains("+ INFO in "+this.getClass().getName())); assertTrue(result.contains("ch.qos.logback.core.util.StatusPrinterTest.testWithException")); } - + + static final String ORIGIN = "statusPrinterTest"; + static final String LS = CoreConstants.LINE_SEPARATOR; + static final String NO_STATUS_MANAGER_WARNING = "WARN: Context named \"noStatusManager\" has no status manager" + LS; + + /** A context whose status manager is missing. */ + static Context contextWithoutStatusManager() { + ContextBase context = new ContextBase() { + @Override + public StatusManager getStatusManager() { + return null; + } + }; + context.setName("noStatusManager"); + return context; + } + + @Test + public void isInstantiable() { + // the class only has static members, but its implicit constructor is public + assertNotNull(new StatusPrinter()); + } + + @Test + public void printInCaseOfErrorsOrWarningsRejectsNullContext() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> StatusPrinter.printInCaseOfErrorsOrWarnings(null)); + assertEquals("Context argument cannot be null", e.getMessage()); + } + + @Test + public void printInCaseOfErrorsOrWarningsWarnsAboutMissingStatusManager() { + StatusPrinter.printInCaseOfErrorsOrWarnings(contextWithoutStatusManager()); + assertEquals(NO_STATUS_MANAGER_WARNING, outputStream.toString()); + } + + @Test + public void printInCaseOfErrorsOrWarningsPrintsOnlyIfThereAreWarnings() { + Context context = new ContextBase(); + context.getStatusManager().add(new InfoStatus("info0", ORIGIN)); + StatusPrinter.printInCaseOfErrorsOrWarnings(context); + assertEquals("", outputStream.toString()); + + context.getStatusManager().add(new WarnStatus("warn0", ORIGIN)); + StatusPrinter.printInCaseOfErrorsOrWarnings(context); + String result = outputStream.toString(); + assertTrue(result.contains("|-INFO in " + ORIGIN + " - info0")); + assertTrue(result.contains("|-WARN in " + ORIGIN + " - warn0")); + } + + @Test + public void printIfErrorsOccuredRejectsNullContext() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> StatusPrinter.printIfErrorsOccured(null)); + assertEquals("Context argument cannot be null", e.getMessage()); + } + + @Test + public void printIfErrorsOccuredWarnsAboutMissingStatusManager() { + StatusPrinter.printIfErrorsOccured(contextWithoutStatusManager()); + assertEquals(NO_STATUS_MANAGER_WARNING, outputStream.toString()); + } + + @Test + public void printIfErrorsOccuredPrintsOnlyIfThereAreErrors() { + Context context = new ContextBase(); + context.getStatusManager().add(new WarnStatus("warn0", ORIGIN)); + StatusPrinter.printIfErrorsOccured(context); + assertEquals("", outputStream.toString()); + + context.getStatusManager().add(new ErrorStatus("error0", ORIGIN)); + StatusPrinter.printIfErrorsOccured(context); + String result = outputStream.toString(); + assertTrue(result.contains("|-WARN in " + ORIGIN + " - warn0")); + assertTrue(result.contains("|-ERROR in " + ORIGIN + " - error0")); + } + + @Test + public void printRejectsNullContext() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> StatusPrinter.print((Context) null)); + assertEquals("Context argument cannot be null", e.getMessage()); + } + + @Test + public void printWarnsAboutMissingStatusManager() { + StatusPrinter.print(contextWithoutStatusManager()); + assertEquals(NO_STATUS_MANAGER_WARNING, outputStream.toString()); + } + + @Test + public void printStatusList() { + List statusList = new ArrayList(); + statusList.add(new InfoStatus("info0", ORIGIN)); + statusList.add(new ErrorStatus("error0", ORIGIN)); + + StatusPrinter.print(statusList); + + String[] lines = outputStream.toString().split(LS); + assertEquals(2, lines.length); + assertTrue(lines[0], lines[0].endsWith("|-INFO in " + ORIGIN + " - info0")); + assertTrue(lines[1], lines[1].endsWith("|-ERROR in " + ORIGIN + " - error0")); + } + + @Test + public void printNullStatusListPrintsAnEmptyLine() { + StatusPrinter.print((List) null); + assertEquals(LS, outputStream.toString()); + } + + @Test + public void buildStrOmitsTheDateWithoutDateFormatter() { + CachingDateFormatter original = StatusPrinter.cachingDateFormat; + StatusPrinter.cachingDateFormat = null; + try { + StringBuilder sb = new StringBuilder(); + StatusPrinter.buildStr(sb, "", new InfoStatus("info0", ORIGIN)); + assertEquals("|-INFO in " + ORIGIN + " - info0" + LS, sb.toString()); + } finally { + StatusPrinter.cachingDateFormat = original; + } + } + + @Test + public void buildStrPrefixesEachThrowableLineByItsKind() { + Throwable t = new Exception("boom"); + CachingDateFormatter original = StatusPrinter.cachingDateFormat; + StatusPrinter.cachingDateFormat = null; + try (MockedStatic converter = mockStatic(ThrowableToStringArray.class)) { + converter.when(() -> ThrowableToStringArray.convert(t)).thenReturn(new String[] { + "java.lang.Exception: boom", + "Caused by: java.lang.IllegalStateException: cause", + "12 common frames omitted" + }); + StringBuilder sb = new StringBuilder(); + StatusPrinter.buildStr(sb, "", new ErrorStatus("error0", ORIGIN, t)); + + assertEquals("|-ERROR in " + ORIGIN + " - error0 java.lang.Exception: boom" + LS + + "\tat java.lang.Exception: boom" + LS + + "Caused by: java.lang.IllegalStateException: cause" + LS + + "\t... 12 common frames omitted" + LS, sb.toString()); + } finally { + StatusPrinter.cachingDateFormat = original; + } + } + } diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/StringCollectionUtilTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/StringCollectionUtilTest.java index c824bf2bd..4cb1a8ead 100755 --- a/logback-android/src/test/java/ch/qos/logback/core/util/StringCollectionUtilTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/util/StringCollectionUtilTest.java @@ -15,6 +15,7 @@ */ package ch.qos.logback.core.util; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import java.util.ArrayList; @@ -74,6 +75,12 @@ public void testRemoveMatchingWithNoMatchingPattern() throws Exception { assertTrue(values.contains("A")); } + @Test + public void isInstantiable() { + // the class only has static members, but its implicit constructor is public + assertNotNull(new StringCollectionUtil()); + } + private List stringToList(String... values) { List result = new ArrayList(values.length); result.addAll(Arrays.asList(values)); From 0ea690a1ce3374314ae18fbbd45c28a5bc856d4a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:01:24 +0000 Subject: [PATCH 07/79] test: note the JDK dependency of LoaderTest's permission tests The getClassLoader permission tests re-run Loader's static initializer in a restricted protection domain, which relies on AccessController checking permissions. That holds on CI's JDK 17 and 21 but not from JDK 24 on, where checkPermission always throws; say so next to the tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../src/test/java/ch/qos/logback/core/util/LoaderTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/logback-android/src/test/java/ch/qos/logback/core/util/LoaderTest.java b/logback-android/src/test/java/ch/qos/logback/core/util/LoaderTest.java index 2bc70b4e4..e45ac9c4b 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/util/LoaderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/util/LoaderTest.java @@ -197,6 +197,9 @@ public void ignoreTCLPropertyFalseKeepsLoadClassOnTheContextClassLoader() throws assertEquals(Collections.singletonList(FileSize.class.getName()), tcl.requested); } + // Loader's permission check evaluates the protection domain of the copy. + // That needs a functional AccessController (JDK 23 and older, e.g. CI's + // JDK 17 and 21); from JDK 24 on, checkPermission always throws. @Test public void classLoaderAsPrivilegedIsAvailableWithGetClassLoaderPermission() throws Exception { Permissions all = new Permissions(); From e4b35a752fc92c5fc5e507cfeb3944b417b3c7ea Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:43:30 +0000 Subject: [PATCH 08/79] refactor: remove unreachable code from SaxEventRecorder and ElementPath Both statements can never execute, so the 100% line/branch coverage gate cannot be met while they exist. Behavior is unchanged for every input. - SaxEventRecorder.recordEvents() ended in `throw new IllegalStateException("This point can never be reached")` after its catch blocks. Every catch block already ended the method, because handleError() always throws, but javac could not see that. handleError() now returns the JoranException and each catch block throws it, so the dead statement is gone. The exceptions and status messages are the same. - ElementPath(String) checked `if (partArray == null) return;` after String.split(), which never returns null. SaxEventRecorderHandlerTest drives each recordEvents() failure path (EOF, I/O, SAX, runtime, parser configuration) through a Mockito construction mock of the parser. It also covers the handler callbacks. ElementPathTest pins how the string constructor splits paths. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../core/joran/event/SaxEventRecorder.java | 13 +- .../logback/core/joran/spi/ElementPath.java | 2 - .../event/SaxEventRecorderHandlerTest.java | 329 ++++++++++++++++++ .../core/joran/spi/ElementPathTest.java | 69 ++++ 4 files changed, 405 insertions(+), 8 deletions(-) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/event/SaxEventRecorderHandlerTest.java diff --git a/logback-android/src/main/java/ch/qos/logback/core/joran/event/SaxEventRecorder.java b/logback-android/src/main/java/ch/qos/logback/core/joran/event/SaxEventRecorder.java index a5d14115e..addfaea5b 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/joran/event/SaxEventRecorder.java +++ b/logback-android/src/main/java/ch/qos/logback/core/joran/event/SaxEventRecorder.java @@ -69,22 +69,23 @@ public List recordEvents(InputSource inputSource) parser.parse(inputSource); return saxEventList; } catch (EOFException eof) { - handleError(eof.getLocalizedMessage(), + throw handleError(eof.getLocalizedMessage(), new SAXParseException(eof.getLocalizedMessage(), locator, eof)); } catch (IOException ie) { - handleError("I/O error occurred while parsing xml file", ie); + throw handleError("I/O error occurred while parsing xml file", ie); } catch(SAXException se) { // Exception added into StatusManager via Sax error handling. No need to add it again throw new JoranException("Problem parsing XML document. See previously reported errors.", se); } catch (Exception ex) { - handleError("Unexpected exception while parsing XML document.", ex); + throw handleError("Unexpected exception while parsing XML document.", ex); } - throw new IllegalStateException("This point can never be reached"); } - private void handleError(String errMsg, Throwable t) throws JoranException { + // returns (rather than throws) the exception so that every catch block above + // visibly ends in a throw and no unreachable statement is needed after them + private JoranException handleError(String errMsg, Throwable t) { addError(errMsg, t); - throw new JoranException(errMsg, t); + return new JoranException(errMsg, t); } private Driver buildPullParser() throws JoranException { diff --git a/logback-android/src/main/java/ch/qos/logback/core/joran/spi/ElementPath.java b/logback-android/src/main/java/ch/qos/logback/core/joran/spi/ElementPath.java index 96866a377..515ba1ca4 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/joran/spi/ElementPath.java +++ b/logback-android/src/main/java/ch/qos/logback/core/joran/spi/ElementPath.java @@ -47,8 +47,6 @@ public ElementPath(String pathStr) { } String[] partArray = pathStr.split("/"); - if(partArray == null) return; - for(String part: partArray) { if(part.length() >0) { partList.add(part); diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/event/SaxEventRecorderHandlerTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/event/SaxEventRecorderHandlerTest.java new file mode 100644 index 000000000..1dd289a91 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/event/SaxEventRecorderHandlerTest.java @@ -0,0 +1,329 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.event; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.io.EOFException; +import java.io.IOException; +import java.io.StringReader; +import java.util.List; + +import org.junit.Test; +import org.mockito.MockedConstruction; +import org.xml.sax.InputSource; +import org.xml.sax.Locator; +import org.xml.sax.SAXException; +import org.xml.sax.SAXNotRecognizedException; +import org.xml.sax.SAXNotSupportedException; +import org.xml.sax.SAXParseException; +import org.xml.sax.helpers.AttributesImpl; +import org.xml.sax.helpers.LocatorImpl; +import org.xmlpull.v1.sax2.Driver; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.joran.spi.JoranException; +import ch.qos.logback.core.status.InfoStatus; +import ch.qos.logback.core.status.Status; + +/** + * Plain-JVM tests of {@link SaxEventRecorder} as a SAX handler, and of how + * {@link SaxEventRecorder#recordEvents(InputSource)} reports failures. + * + *

The XML parser (Android's {@link Driver}) is replaced by a Mockito + * construction mock, so these tests drive the handler callbacks directly. + * Parsing real documents is covered by the Robolectric + * {@link SaxEventRecorderTest}. + */ +public class SaxEventRecorderHandlerTest { + + private static final String VALIDATION_FEATURE = "http://xml.org/sax/features/validation"; + private static final String NAMESPACES_FEATURE = "http://xml.org/sax/features/namespaces"; + + private final Context context = new ContextBase(); + private final SaxEventRecorder recorder = new SaxEventRecorder(context); + + private static Locator locatorAt(int line, int column) { + LocatorImpl locator = new LocatorImpl(); + locator.setLineNumber(line); + locator.setColumnNumber(column); + return locator; + } + + private static InputSource someInput() { + return new InputSource(new StringReader("")); + } + + private List statuses() { + return context.getStatusManager().getCopyOfStatusList(); + } + + private void assertStatus(Status status, int level, String message, Throwable throwable) { + assertEquals(level, status.getLevel()); + assertEquals(message, status.getMessage()); + assertSame(throwable, status.getThrowable()); + } + + @Test + public void defaultConstructorLeavesContextUnsetUntilSetContext() { + SaxEventRecorder noContextRecorder = new SaxEventRecorder(); + assertNull(noContextRecorder.getContext()); + assertTrue(noContextRecorder.getSaxEventList().isEmpty()); + + noContextRecorder.setContext(context); + assertSame(context, noContextRecorder.getContext()); + } + + @Test + public void qNameFallsBackToLocalNameWhenNullOrEmpty() { + recorder.setDocumentLocator(locatorAt(1, 1)); + recorder.startElement("urn:x", "outer", null, new AttributesImpl()); + recorder.startElement("urn:x", "inner", "", new AttributesImpl()); + recorder.endElement("urn:x", "inner", ""); + recorder.endElement("urn:x", "outer", null); + + List events = recorder.getSaxEventList(); + assertEquals(4, events.size()); + StartEvent outer = (StartEvent) events.get(0); + assertEquals("outer", outer.getQName()); + assertEquals("outer", outer.getLocalName()); + assertEquals("urn:x", outer.getNamespaceURI()); + assertEquals("[outer]", outer.elementPath.toString()); + StartEvent inner = (StartEvent) events.get(1); + assertEquals("inner", inner.getQName()); + assertEquals("[outer][inner]", inner.elementPath.toString()); + assertTrue(events.get(2) instanceof EndEvent); + assertEquals("inner", events.get(2).getQName()); + assertTrue(events.get(3) instanceof EndEvent); + assertEquals("outer", events.get(3).getQName()); + } + + @Test + public void elementPathUsesQNameWhenLocalNameIsNullOrEmpty() { + recorder.setDocumentLocator(locatorAt(1, 1)); + recorder.startElement("", null, "p:outer", new AttributesImpl()); + recorder.startElement("", "", "p:inner", new AttributesImpl()); + + List events = recorder.getSaxEventList(); + assertEquals("[p:outer]", ((StartEvent) events.get(0)).elementPath.toString()); + assertEquals("[p:outer][p:inner]", ((StartEvent) events.get(1)).elementPath.toString()); + assertEquals("local", recorder.getTagName("local", "p:q")); + assertEquals("p:q", recorder.getTagName(null, "p:q")); + assertEquals("p:q", recorder.getTagName("", "p:q")); + } + + @Test + public void consecutiveCharacterChunksAreAppendedToOneBodyEvent() { + recorder.setDocumentLocator(locatorAt(2, 3)); + recorder.startElement("", "x", "x", new AttributesImpl()); + char[] chunk1 = " hello ".toCharArray(); + char[] chunk2 = "..world..".toCharArray(); + recorder.characters(chunk1, 0, chunk1.length); + recorder.characters(chunk2, 2, 5); + + List events = recorder.getSaxEventList(); + assertEquals(2, events.size()); + assertEquals("hello world", ((BodyEvent) events.get(1)).getText()); + } + + @Test + public void characterDataBeforeAnyEventIsRecordedUnlessBlank() { + recorder.setDocumentLocator(locatorAt(1, 1)); + assertNull(recorder.getLastEvent()); + + char[] blank = " \n\t ".toCharArray(); + recorder.characters(blank, 0, blank.length); + assertTrue(recorder.getSaxEventList().isEmpty()); + + char[] text = "text".toCharArray(); + recorder.characters(text, 0, text.length); + assertEquals(1, recorder.getSaxEventList().size()); + assertEquals("text", ((BodyEvent) recorder.getLastEvent()).getText()); + } + + @Test + public void parserCallbacksReportPositionAtTheMatchingLevel() throws SAXException { + SAXParseException spe = new SAXParseException("bad token", "pub", "sys", 3, 9); + + recorder.warning(spe); + recorder.error(spe); + recorder.fatalError(spe); + + List statuses = statuses(); + assertEquals(3, statuses.size()); + assertStatus(statuses.get(0), Status.WARN, + CoreConstants.XML_PARSING + " - Parsing warning on line 3 and column 9", spe); + assertStatus(statuses.get(1), Status.ERROR, + CoreConstants.XML_PARSING + " - Parsing error on line 3 and column 9", spe); + assertStatus(statuses.get(2), Status.ERROR, + CoreConstants.XML_PARSING + " - Parsing fatal error on line 3 and column 9", spe); + } + + @Test + public void statusMethodsReportToTheContextWithRecorderAsOrigin() { + Throwable t = new Exception("cause"); + Status custom = new InfoStatus("custom", "someOrigin"); + + recorder.addError("e1"); + recorder.addError("e2", t); + recorder.addInfo("i1"); + recorder.addInfo("i2", t); + recorder.addWarn("w1"); + recorder.addWarn("w2", t); + recorder.addStatus(custom); + + List statuses = statuses(); + assertEquals(7, statuses.size()); + assertStatus(statuses.get(0), Status.ERROR, "e1", null); + assertStatus(statuses.get(1), Status.ERROR, "e2", t); + assertStatus(statuses.get(2), Status.INFO, "i1", null); + assertStatus(statuses.get(3), Status.INFO, "i2", t); + assertStatus(statuses.get(4), Status.WARN, "w1", null); + assertStatus(statuses.get(5), Status.WARN, "w2", t); + for (int i = 0; i < 6; i++) { + assertSame(recorder, statuses.get(i).getOrigin()); + } + assertSame(custom, statuses.get(6)); + } + + @Test + public void recordEventsIgnoresUnsupportedValidationFeatureAndReturnsRecordedEvents() throws Exception { + try (MockedConstruction drivers = mockConstruction(Driver.class, (driver, ctx) -> { + doThrow(new SAXNotSupportedException("no validation")) + .when(driver).setFeature(VALIDATION_FEATURE, false); + doAnswer(invocation -> { + recorder.setDocumentLocator(locatorAt(1, 1)); + recorder.startElement("", "x", "x", new AttributesImpl()); + recorder.endElement("", "x", "x"); + return null; + }).when(driver).parse(any(InputSource.class)); + })) { + InputSource input = someInput(); + List events = recorder.recordEvents(input); + + assertSame(recorder.getSaxEventList(), events); + assertEquals(2, events.size()); + assertTrue(events.get(0) instanceof StartEvent); + assertTrue(events.get(1) instanceof EndEvent); + + assertEquals(1, drivers.constructed().size()); + Driver driver = drivers.constructed().get(0); + verify(driver).setFeature(NAMESPACES_FEATURE, true); + verify(driver).setContentHandler(recorder); + verify(driver).setErrorHandler(recorder); + verify(driver).parse(input); + assertTrue(statuses().isEmpty()); + } + } + + @Test + public void parserConfigurationFailureIsReportedAndNothingIsParsed() throws Exception { + SAXNotRecognizedException failure = new SAXNotRecognizedException("no namespaces"); + try (MockedConstruction drivers = mockConstruction(Driver.class, (driver, ctx) -> + doThrow(failure).when(driver).setFeature(NAMESPACES_FEATURE, true))) { + + JoranException e = assertThrows(JoranException.class, () -> recorder.recordEvents(someInput())); + + assertEquals("Parser configuration error occurred", e.getMessage()); + assertSame(failure, e.getCause()); + verify(drivers.constructed().get(0), never()).parse(any(InputSource.class)); + List statuses = statuses(); + assertEquals(1, statuses.size()); + assertStatus(statuses.get(0), Status.ERROR, "Parser configuration error occurred", failure); + } + } + + @Test + public void prematureEndOfInputIsReportedAsParseErrorAtLastKnownPosition() throws Exception { + EOFException eof = new EOFException("premature end of document"); + try (MockedConstruction ignored = mockConstruction(Driver.class, (driver, ctx) -> + doAnswer(invocation -> { + recorder.setDocumentLocator(locatorAt(5, 2)); + throw eof; + }).when(driver).parse(any(InputSource.class)))) { + + JoranException e = assertThrows(JoranException.class, () -> recorder.recordEvents(someInput())); + + assertEquals("premature end of document", e.getMessage()); + SAXParseException spe = (SAXParseException) e.getCause(); + assertEquals("premature end of document", spe.getMessage()); + assertEquals(5, spe.getLineNumber()); + assertEquals(2, spe.getColumnNumber()); + assertSame(eof, spe.getException()); + List statuses = statuses(); + assertEquals(1, statuses.size()); + assertStatus(statuses.get(0), Status.ERROR, "premature end of document", spe); + } + } + + @Test + public void ioFailureWhileParsingIsReported() throws Exception { + IOException failure = new IOException("stream broke"); + try (MockedConstruction ignored = mockConstruction(Driver.class, (driver, ctx) -> + doThrow(failure).when(driver).parse(any(InputSource.class)))) { + + JoranException e = assertThrows(JoranException.class, () -> recorder.recordEvents(someInput())); + + assertEquals("I/O error occurred while parsing xml file", e.getMessage()); + assertSame(failure, e.getCause()); + List statuses = statuses(); + assertEquals(1, statuses.size()); + assertStatus(statuses.get(0), Status.ERROR, "I/O error occurred while parsing xml file", failure); + } + } + + @Test + public void saxExceptionIsWrappedWithoutReportingItAgain() throws Exception { + SAXException failure = new SAXException("aborted by error handler"); + try (MockedConstruction ignored = mockConstruction(Driver.class, (driver, ctx) -> + doThrow(failure).when(driver).parse(any(InputSource.class)))) { + + JoranException e = assertThrows(JoranException.class, () -> recorder.recordEvents(someInput())); + + assertEquals("Problem parsing XML document. See previously reported errors.", e.getMessage()); + assertSame(failure, e.getCause()); + assertTrue(statuses().isEmpty()); + } + } + + @Test + public void unexpectedRuntimeExceptionWhileParsingIsReported() throws Exception { + IllegalStateException failure = new IllegalStateException("boom"); + try (MockedConstruction ignored = mockConstruction(Driver.class, (driver, ctx) -> + doThrow(failure).when(driver).parse(any(InputSource.class)))) { + + JoranException e = assertThrows(JoranException.class, () -> recorder.recordEvents(someInput())); + + assertEquals("Unexpected exception while parsing XML document.", e.getMessage()); + assertSame(failure, e.getCause()); + List statuses = statuses(); + assertEquals(1, statuses.size()); + assertStatus(statuses.get(0), Status.ERROR, "Unexpected exception while parsing XML document.", failure); + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/spi/ElementPathTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/ElementPathTest.java index c3dc764d0..94c613f01 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/spi/ElementPathTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/ElementPathTest.java @@ -16,7 +16,13 @@ package ch.qos.logback.core.joran.spi; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import org.junit.Test; @@ -36,4 +42,67 @@ public void differentPathsAreNotEqual() { assertNotEquals(new ElementPath("a/b"), new ElementPath("a/c")); assertNotEquals(new ElementPath("a/b"), new ElementPath("ab")); } + + @Test + public void stringConstructorIgnoresEmptySegments() { + ElementPath p = new ElementPath("//a///b/"); + assertEquals(2, p.size()); + assertEquals("a", p.get(0)); + assertEquals("b", p.get(1)); + assertEquals(0, new ElementPath("").size()); + assertEquals(0, new ElementPath("/").size()); + } + + @Test + public void nullStringYieldsEmptyPath() { + ElementPath p = new ElementPath((String) null); + assertEquals(0, p.size()); + assertEquals("", p.toString()); + } + + @Test + public void listConstructorCopiesTheParts() { + List parts = new ArrayList(Arrays.asList("a", "b")); + ElementPath p = new ElementPath(parts); + parts.add("c"); + + assertEquals(2, p.size()); + assertEquals("[a][b]", p.toString()); + assertEquals(new ElementPath("a/b"), p); + } + + @Test + public void isNeverEqualToNullOrOtherTypes() { + ElementPath p = new ElementPath("a/b"); + assertFalse(p.equals(null)); + assertFalse(p.equals("a/b")); + } + + @Test + public void copyOfPartListIsDetachedFromThePath() { + ElementPath p = new ElementPath("a/b"); + List copy = p.getCopyOfPartList(); + assertEquals(Arrays.asList("a", "b"), copy); + + copy.clear(); + assertEquals(2, p.size()); + } + + @Test + public void popRemovesLastPartAndIsANoOpWhenEmpty() { + ElementPath p = new ElementPath("a/b"); + p.pop(); + assertEquals("[a]", p.toString()); + p.pop(); + assertEquals(0, p.size()); + p.pop(); + assertEquals(0, p.size()); + } + + @Test + public void peekLastReturnsLastPartOrNullWhenEmpty() { + ElementPath p = new ElementPath("a/b"); + assertEquals("b", p.peekLast()); + assertNull(new ElementPath().peekLast()); + } } From 1912f396802e363fa4dc3729b02f83025b9ed3a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:43:39 +0000 Subject: [PATCH 09/79] test: cover GenericConfigurator and the joran event/spi classes Closes the unit-test coverage gaps in GenericConfigurator, BodyEvent, SaxEvent, StartEvent, ConfigurationWatchList, ConsoleTarget, ElementPath, ElementSelector, EventPlayer, HostClassAndPropertyDouble, InterpretationContext, Interpreter, JoranException, NoAutoStartUtil and SimpleRuleStore, using plain JVM tests. What the tests pin: - GenericConfigurator: a URL that cannot be opened, and a stream that fails to close, are both reported as an error status and a JoranException. The URL is still registered as the watched URL. - Events: body text is trimmed and appended. Locators and attributes are snapshots. The toString formats are fixed. - ConsoleTarget: each stream writes to whatever System.out/System.err currently is. findByName ignores case. - ElementSelector/SimpleRuleStore: null and empty paths, case-insensitive equals/hashCode, middle (*/x/*) matching with the longest match winning, a lone "*" matching nothing, and adding a rule by class name (success, and an error status for a non-Action class). - Interpreter/InterpretationContext: missing or blank body text is not passed to actions. A failing body action is reported and later actions still run. Null action lists are ignored. The error origin includes the locator position, or NA:NA when there is none. Also covered: property trimming and null handling, and a duplicate in-play listener being warned about and notified only once. - HostClassAndPropertyDouble: equals/hashCode with null members. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../core/joran/GenericConfiguratorTest.java | 113 +++++++++ .../core/joran/event/BodyEventTest.java | 57 +++++ .../core/joran/event/SaxEventTest.java | 52 +++++ .../core/joran/event/StartEventTest.java | 77 +++++++ .../joran/spi/ConfigurationWatchListTest.java | 32 +++ .../core/joran/spi/ConsoleTargetTest.java | 99 ++++++++ .../core/joran/spi/ElementSelectorTest.java | 64 ++++++ .../core/joran/spi/EventPlayerTest.java | 60 +++++ .../spi/HostClassAndPropertyDoubleTest.java | 81 +++++++ .../joran/spi/InterpretationContextTest.java | 133 +++++++++++ .../core/joran/spi/InterpreterTest.java | 215 ++++++++++++++++++ .../core/joran/spi/JoranExceptionTest.java | 40 ++++ .../core/joran/spi/NoAutoStartUtilTest.java | 12 + .../core/joran/spi/SimpleRuleStoreTest.java | 81 +++++++ 14 files changed, 1116 insertions(+) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/GenericConfiguratorTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/event/BodyEventTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/event/SaxEventTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/event/StartEventTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/spi/ConsoleTargetTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/spi/EventPlayerTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/spi/HostClassAndPropertyDoubleTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/spi/InterpretationContextTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/spi/InterpreterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/spi/JoranExceptionTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/GenericConfiguratorTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/GenericConfiguratorTest.java new file mode 100644 index 000000000..c0dd38de9 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/GenericConfiguratorTest.java @@ -0,0 +1,113 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mockConstruction; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; +import java.util.HashMap; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedConstruction; +import org.xmlpull.v1.sax2.Driver; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.joran.action.Action; +import ch.qos.logback.core.joran.spi.ElementSelector; +import ch.qos.logback.core.joran.spi.JoranException; +import ch.qos.logback.core.joran.util.ConfigurationWatchListUtil; +import ch.qos.logback.core.status.Status; + +/** + * Plain-JVM tests of {@link GenericConfigurator}'s I/O error handling. + * Configuring from real documents is covered by the Robolectric + * {@link TrivialConfiguratorTest}. + */ +public class GenericConfiguratorTest { + + private final Context context = new ContextBase(); + private final TrivialConfigurator configurator = + new TrivialConfigurator(new HashMap()); + + @Before + public void setUp() { + configurator.setContext(context); + } + + private Status errorStatus(String message) { + for (Status status : context.getStatusManager().getCopyOfStatusList()) { + if (status.getLevel() == Status.ERROR && message.equals(status.getMessage())) { + return status; + } + } + throw new AssertionError("no error status [" + message + "] in " + + context.getStatusManager().getCopyOfStatusList()); + } + + @Test + public void urlThatCannotBeOpenedIsReportedButStillWatched() throws Exception { + final IOException failure = new IOException("connection refused"); + URL url = new URL(null, "test:config.xml", new URLStreamHandler() { + @Override + protected URLConnection openConnection(URL u) throws IOException { + throw failure; + } + }); + + JoranException e = assertThrows(JoranException.class, () -> configurator.doConfigure(url)); + + assertEquals("Could not open URL [test:config.xml].", e.getMessage()); + assertSame(failure, e.getCause()); + assertSame(failure, errorStatus("Could not open URL [test:config.xml].").getThrowable()); + assertSame(url, ConfigurationWatchListUtil.getMainWatchURL(context)); + } + + @Test + public void streamThatFailsToCloseIsReportedAfterConfiguring() throws Exception { + final IOException failure = new IOException("close failed"); + InputStream in = new ByteArrayInputStream("".getBytes("UTF-8")) { + @Override + public void close() throws IOException { + throw failure; + } + }; + + // stand-in parser that reports no events: the configuration itself succeeds + try (MockedConstruction ignored = mockConstruction(Driver.class)) { + JoranException e = assertThrows(JoranException.class, () -> configurator.doConfigure(in)); + + assertEquals("Could not close the stream", e.getMessage()); + assertSame(failure, e.getCause()); + } + assertSame(failure, errorStatus("Could not close the stream").getThrowable()); + List safeConfiguration = configurator.recallSafeConfiguration(); + assertNotNull(safeConfiguration); + assertTrue(safeConfiguration.isEmpty()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/event/BodyEventTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/event/BodyEventTest.java new file mode 100644 index 000000000..b582210d7 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/event/BodyEventTest.java @@ -0,0 +1,57 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.event; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; +import org.xml.sax.helpers.LocatorImpl; + +public class BodyEventTest { + + private static LocatorImpl locatorAt(int line, int column) { + LocatorImpl locator = new LocatorImpl(); + locator.setLineNumber(line); + locator.setColumnNumber(column); + return locator; + } + + @Test + public void textIsTrimmed() { + BodyEvent be = new BodyEvent(" some text \n", locatorAt(1, 1)); + assertEquals("some text", be.getText()); + } + + @Test + public void nullTextIsReturnedAsNull() { + BodyEvent be = new BodyEvent(null, locatorAt(1, 1)); + assertNull(be.getText()); + } + + @Test + public void appendConcatenatesUntrimmedChunks() { + BodyEvent be = new BodyEvent(" hello ", locatorAt(1, 1)); + be.append("world "); + assertEquals("hello world", be.getText()); + } + + @Test + public void toStringShowsTrimmedTextAndPosition() { + BodyEvent be = new BodyEvent(" hi ", locatorAt(4, 12)); + assertEquals("BodyEvent(hi)4,12", be.toString()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/event/SaxEventTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/event/SaxEventTest.java new file mode 100644 index 000000000..073884cd5 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/event/SaxEventTest.java @@ -0,0 +1,52 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.event; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; + +import org.junit.Test; +import org.xml.sax.helpers.LocatorImpl; + +public class SaxEventTest { + + @Test + public void gettersReturnConstructorArguments() { + SaxEvent event = new SaxEvent("urn:ns", "local", "p:local", new LocatorImpl()); + + assertEquals("urn:ns", event.getNamespaceURI()); + assertEquals("local", event.getLocalName()); + assertEquals("p:local", event.getQName()); + } + + @Test + public void locatorIsASnapshotOfTheParserPosition() { + LocatorImpl parserLocator = new LocatorImpl(); + parserLocator.setSystemId("file:/config.xml"); + parserLocator.setLineNumber(7); + parserLocator.setColumnNumber(3); + + SaxEvent event = new SaxEvent("", "x", "x", parserLocator); + // the parser moves on + parserLocator.setLineNumber(8); + parserLocator.setColumnNumber(1); + + assertNotSame(parserLocator, event.getLocator()); + assertEquals("file:/config.xml", event.getLocator().getSystemId()); + assertEquals(7, event.getLocator().getLineNumber()); + assertEquals(3, event.getLocator().getColumnNumber()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/event/StartEventTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/event/StartEventTest.java new file mode 100644 index 000000000..3696bbbf6 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/event/StartEventTest.java @@ -0,0 +1,77 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.event; + +import static org.junit.Assert.assertEquals; + +import java.lang.reflect.Field; + +import org.junit.Test; +import org.xml.sax.helpers.AttributesImpl; +import org.xml.sax.helpers.LocatorImpl; + +import ch.qos.logback.core.joran.spi.ElementPath; + +public class StartEventTest { + + private static LocatorImpl locatorAt(int line, int column) { + LocatorImpl locator = new LocatorImpl(); + locator.setLineNumber(line); + locator.setColumnNumber(column); + return locator; + } + + private static StartEvent startEvent(AttributesImpl attributes) { + return new StartEvent(new ElementPath("a/q"), "", "q", "q", attributes, locatorAt(2, 5)); + } + + @Test + public void toStringListsAttributesAndPosition() { + AttributesImpl attributes = new AttributesImpl(); + attributes.addAttribute("", "a", "a", "CDATA", "1"); + attributes.addAttribute("", "b", "b", "CDATA", "2"); + + // (no separator between the name and the first attribute, as upstream) + assertEquals("StartEvent(qa=\"1\" b=\"2\") [2,5]", startEvent(attributes).toString()); + } + + @Test + public void attributesAreASnapshot() { + AttributesImpl attributes = new AttributesImpl(); + attributes.addAttribute("", "a", "a", "CDATA", "1"); + StartEvent event = startEvent(attributes); + + // the parser reuses its attributes object for the next element + attributes.clear(); + attributes.addAttribute("", "z", "z", "CDATA", "9"); + + assertEquals(1, event.getAttributes().getLength()); + assertEquals("1", event.getAttributes().getValue("a")); + assertEquals("[a][q]", event.elementPath.toString()); + } + + @Test + public void toStringToleratesMissingAttributes() throws Exception { + StartEvent event = startEvent(new AttributesImpl()); + // the constructor always copies the attributes; null them out to exercise + // toString's defensive check + Field attributesField = StartEvent.class.getField("attributes"); + attributesField.setAccessible(true); + attributesField.set(event, null); + + assertEquals("StartEvent(q) [2,5]", event.toString()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/spi/ConfigurationWatchListTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/ConfigurationWatchListTest.java index 87ed6f83d..5320727f4 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/spi/ConfigurationWatchListTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/ConfigurationWatchListTest.java @@ -16,12 +16,21 @@ package ch.qos.logback.core.joran.spi; import org.junit.Test; +import org.mockito.MockedStatic; import java.io.File; +import java.io.UnsupportedEncodingException; import java.net.MalformedURLException; import java.net.URL; +import java.net.URLDecoder; import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNull; +import static junit.framework.Assert.assertSame; +import static junit.framework.Assert.assertTrue; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mockStatic; /** * @author Ceki Gücü @@ -37,4 +46,27 @@ public void fileToURLAndBack() throws MalformedURLException { File back = cwl.convertToFile(url); assertEquals(file.getName(), back.getName()); } + + @Test + public void nullMainUrlIsAcceptedAndWatchesNothing() { + ConfigurationWatchList cwl = new ConfigurationWatchList(); + cwl.setMainURL(null); + + assertNull(cwl.getMainURL()); + assertTrue(cwl.getCopyOfFileWatchList().isEmpty()); + } + + @Test + public void undecodableFileUrlIsReportedAsIllegalState() throws MalformedURLException { + URL url = new File("a b.xml").toURI().toURL(); + ConfigurationWatchList cwl = new ConfigurationWatchList(); + UnsupportedEncodingException failure = new UnsupportedEncodingException("UTF-8"); + + try (MockedStatic decoder = mockStatic(URLDecoder.class)) { + decoder.when(() -> URLDecoder.decode(anyString(), anyString())).thenThrow(failure); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> cwl.convertToFile(url)); + assertSame(failure, e.getCause()); + } + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/spi/ConsoleTargetTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/ConsoleTargetTest.java new file mode 100644 index 000000000..cdc3e2e0c --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/ConsoleTargetTest.java @@ -0,0 +1,99 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.spi; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintStream; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +@SuppressWarnings("deprecation") +public class ConsoleTargetTest { + + private PrintStream originalOut; + private PrintStream originalErr; + private final ByteArrayOutputStream capturedOut = new ByteArrayOutputStream(); + private final ByteArrayOutputStream capturedErr = new ByteArrayOutputStream(); + + @Before + public void redirectConsole() { + originalOut = System.out; + originalErr = System.err; + System.setOut(new PrintStream(capturedOut)); + System.setErr(new PrintStream(capturedErr)); + } + + @After + public void restoreConsole() { + System.setOut(originalOut); + System.setErr(originalErr); + } + + private static void writeAllWays(OutputStream stream) throws IOException { + stream.write('A'); + stream.write(new byte[] {'B', 'C'}); + stream.write(new byte[] {'x', 'D', 'E', 'x'}, 1, 2); + stream.flush(); + } + + private static final byte[] EXPECTED = {'A', 'B', 'C', 'D', 'E'}; + + @Test + public void systemOutStreamWritesToTheCurrentSystemOut() throws IOException { + writeAllWays(ConsoleTarget.SystemOut.getStream()); + + assertArrayEquals(EXPECTED, capturedOut.toByteArray()); + assertEquals(0, capturedErr.size()); + } + + @Test + public void systemErrStreamWritesToTheCurrentSystemErr() throws IOException { + writeAllWays(ConsoleTarget.SystemErr.getStream()); + + assertArrayEquals(EXPECTED, capturedErr.toByteArray()); + assertEquals(0, capturedOut.size()); + } + + @Test + public void findByNameIgnoresCase() { + assertSame(ConsoleTarget.SystemOut, ConsoleTarget.findByName("System.out")); + assertSame(ConsoleTarget.SystemOut, ConsoleTarget.findByName("SYSTEM.OUT")); + assertSame(ConsoleTarget.SystemErr, ConsoleTarget.findByName("system.err")); + } + + @Test + public void findByNameReturnsNullForUnknownNames() { + assertNull(ConsoleTarget.findByName("System.in")); + assertNull(ConsoleTarget.findByName("SystemOut")); + } + + @Test + public void nameAndToStringAreTheDisplayName() { + assertEquals("System.out", ConsoleTarget.SystemOut.getName()); + assertEquals("System.err", ConsoleTarget.SystemErr.getName()); + assertEquals("System.out", ConsoleTarget.SystemOut.toString()); + assertEquals("System.err", ConsoleTarget.SystemErr.toString()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/spi/ElementSelectorTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/ElementSelectorTest.java index 38f0a280e..60297685e 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/spi/ElementSelectorTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/ElementSelectorTest.java @@ -16,6 +16,11 @@ package ch.qos.logback.core.joran.spi; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; import org.junit.Test; @@ -178,4 +183,63 @@ public void testPrefixMatch() { } } + @Test + public void noArgAndListConstructors() { + assertEquals(0, new ElementSelector().size()); + + ElementSelector fromList = new ElementSelector(Arrays.asList("a", "b")); + assertEquals(new ElementSelector("a/b"), fromList); + } + + @Test + public void tailMatchLengthIsZeroForNullOrEmptyPaths() { + ElementSelector selector = new ElementSelector("*/a"); + assertEquals(0, selector.getTailMatchLength(null)); + assertEquals(0, selector.getTailMatchLength(new ElementPath())); + assertEquals(0, new ElementSelector().getTailMatchLength(new ElementPath("a"))); + } + + @Test + public void prefixMatchLengthIsZeroForNullOrEmptyPaths() { + ElementSelector selector = new ElementSelector("a/*"); + assertEquals(0, selector.getPrefixMatchLength(null)); + assertEquals(0, selector.getPrefixMatchLength(new ElementPath())); + assertEquals(0, new ElementSelector().getPrefixMatchLength(new ElementPath("a"))); + } + + @Test + public void isContainedInMatchesAContiguousRunOfParts() { + ElementSelector selector = new ElementSelector("b/c"); + assertTrue(selector.isContainedIn(new ElementPath("a/b/c/d"))); + assertTrue(selector.isContainedIn(new ElementPath("b/c"))); + assertFalse(selector.isContainedIn(new ElementPath("a/b/x/c"))); + assertFalse(selector.isContainedIn(new ElementPath("a/bb/c"))); + assertFalse(selector.isContainedIn(null)); + } + + @Test + public void equalityIsCaseInsensitiveWithConsistentHashCode() { + ElementSelector lower = new ElementSelector("a/b"); + ElementSelector mixed = new ElementSelector("A/b"); + + assertEquals(lower, mixed); + assertEquals(lower.hashCode(), mixed.hashCode()); + assertNotEquals(lower, new ElementSelector("a/c")); + assertNotEquals(lower, new ElementSelector("a/b/c")); + } + + @Test + public void isNeverEqualToNullOrAPlainElementPath() { + ElementSelector selector = new ElementSelector("a/b"); + assertFalse(selector.equals(null)); + assertFalse(selector.equals(new ElementPath("a/b"))); + } + + @Test + public void fullPathMatchIsCaseInsensitiveAndRequiresSameLength() { + ElementSelector selector = new ElementSelector("a/b"); + assertTrue(selector.fullPathMatch(new ElementPath("A/B"))); + assertFalse(selector.fullPathMatch(new ElementPath("a/c"))); + assertFalse(selector.fullPathMatch(new ElementPath("a"))); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/spi/EventPlayerTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/EventPlayerTest.java new file mode 100644 index 000000000..1a9a8ef86 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/EventPlayerTest.java @@ -0,0 +1,60 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.spi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.Test; +import org.xml.sax.helpers.AttributesImpl; +import org.xml.sax.helpers.LocatorImpl; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.joran.action.NOPAction; +import ch.qos.logback.core.joran.event.SaxEvent; +import ch.qos.logback.core.joran.event.SaxEventRecorder; + +public class EventPlayerTest { + + @Test + public void copyOfPlayerEventListIsADetachedCopyOfThePlayedEvents() { + Context context = new ContextBase(); + SimpleRuleStore ruleStore = new SimpleRuleStore(context); + ruleStore.addRule(new ElementSelector("x"), new NOPAction()); + Interpreter interpreter = new Interpreter(context, ruleStore, new ElementPath()); + + SaxEventRecorder recorder = new SaxEventRecorder(context); + recorder.setDocumentLocator(new LocatorImpl()); + recorder.startElement("", "x", "x", new AttributesImpl()); + recorder.endElement("", "x", "x"); + List events = new ArrayList(recorder.getSaxEventList()); + + EventPlayer player = interpreter.getEventPlayer(); + player.play(events); + + List copy = player.getCopyOfPlayerEventList(); + assertEquals(events, copy); + assertNotSame(events, copy); + + copy.clear(); + assertEquals(2, player.getCopyOfPlayerEventList().size()); + assertEquals(0, context.getStatusManager().getCount()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/spi/HostClassAndPropertyDoubleTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/HostClassAndPropertyDoubleTest.java new file mode 100644 index 000000000..e33ec798b --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/HostClassAndPropertyDoubleTest.java @@ -0,0 +1,81 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.spi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class HostClassAndPropertyDoubleTest { + + private static HostClassAndPropertyDouble hcpd(Class hostClass, String propertyName) { + return new HostClassAndPropertyDouble(hostClass, propertyName); + } + + @Test + public void gettersReturnConstructorArguments() { + HostClassAndPropertyDouble d = hcpd(String.class, "encoder"); + assertSame(String.class, d.getHostClass()); + assertEquals("encoder", d.getPropertyName()); + + HostClassAndPropertyDouble empty = hcpd(null, null); + assertNull(empty.getHostClass()); + assertNull(empty.getPropertyName()); + } + + @Test + public void equalByHostClassAndPropertyName() { + HostClassAndPropertyDouble d = hcpd(String.class, "encoder"); + + assertTrue(d.equals(d)); + assertEquals(d, hcpd(String.class, "encoder")); + assertEquals(d.hashCode(), hcpd(String.class, "encoder").hashCode()); + assertNotEquals(d, hcpd(Integer.class, "encoder")); + assertNotEquals(d, hcpd(String.class, "layout")); + assertNotEquals(d, hcpd(String.class, "Encoder")); + } + + @Test + public void neverEqualToNullOrOtherTypes() { + HostClassAndPropertyDouble d = hcpd(String.class, "encoder"); + assertFalse(d.equals(null)); + assertFalse(d.equals("encoder")); + } + + @Test + public void nullMembersAreOnlyEqualToNullMembers() { + assertEquals(hcpd(null, null), hcpd(null, null)); + assertEquals(hcpd(null, "p"), hcpd(null, "p")); + assertEquals(hcpd(String.class, null), hcpd(String.class, null)); + + assertNotEquals(hcpd(null, "p"), hcpd(String.class, "p")); + assertNotEquals(hcpd(String.class, "p"), hcpd(null, "p")); + assertNotEquals(hcpd(String.class, null), hcpd(String.class, "p")); + assertNotEquals(hcpd(String.class, "p"), hcpd(String.class, null)); + } + + @Test + public void hashCodeTreatsNullMembersAsZero() { + assertEquals(31 * 31, hcpd(null, null).hashCode()); + assertEquals(31 * (31 + String.class.hashCode()), hcpd(String.class, null).hashCode()); + assertEquals(31 * 31 + "p".hashCode(), hcpd(null, "p").hashCode()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/spi/InterpretationContextTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/InterpretationContextTest.java new file mode 100644 index 000000000..5e184d0ec --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/InterpretationContextTest.java @@ -0,0 +1,133 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.spi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +import org.junit.Test; +import org.xml.sax.helpers.LocatorImpl; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.joran.event.InPlayListener; +import ch.qos.logback.core.joran.event.SaxEvent; +import ch.qos.logback.core.status.Status; + +public class InterpretationContextTest { + + private final Context context = new ContextBase(); + private final Interpreter interpreter = new Interpreter(context, new SimpleRuleStore(context), new ElementPath()); + private final InterpretationContext ic = interpreter.getInterpretationContext(); + + /** Counts the events it is notified of. */ + static class CountingListener implements InPlayListener { + int count; + + @Override + public void inPlay(SaxEvent event) { + count++; + } + + @Override + public String toString() { + return "CountingListener"; + } + } + + @Test + public void updateLocationInfoAppendsPositionOnlyWhenKnown() { + assertNull(ic.getLocator()); + assertEquals("near ", ic.updateLocationInfo("near ")); + + LocatorImpl locator = new LocatorImpl(); + locator.setLineNumber(3); + locator.setColumnNumber(14); + interpreter.setDocumentLocator(locator); + + assertSame(locator, ic.getLocator()); + assertEquals("near 3:14", ic.updateLocationInfo("near ")); + } + + @Test + public void objectStackIsExposedLive() { + ic.pushObject("a"); + ic.pushObject("b"); + + assertEquals(2, ic.getObjectStack().size()); + assertEquals("a", ic.getObject(0)); + assertEquals("b", ic.getObject(1)); + + ic.getObjectStack().push("c"); + assertEquals("c", ic.peekObject()); + } + + @Test + public void substitutionPropertyWithNullKeyOrValueIsIgnored() { + ic.addSubstitutionProperty(null, "v"); + ic.addSubstitutionProperty("k", null); + assertTrue(ic.getCopyOfPropertyMap().isEmpty()); + + ic.addSubstitutionProperty("k", " v "); + assertEquals("v", ic.getProperty("k")); + } + + @Test + public void substitutionPropertiesAreAddedTrimmedAndNullIsIgnored() { + ic.addSubstitutionProperties(null); + assertTrue(ic.getCopyOfPropertyMap().isEmpty()); + + Properties props = new Properties(); + props.setProperty("a", " 1 "); + props.setProperty("b", "2"); + ic.addSubstitutionProperties(props); + + Map expected = new HashMap(); + expected.put("a", "1"); + expected.put("b", "2"); + assertEquals(expected, ic.getCopyOfPropertyMap()); + } + + @Test + public void inPlayListenerRegisteredTwiceIsWarnedAboutAndNotifiedOnce() { + CountingListener listener = new CountingListener(); + assertTrue(ic.isListenerListEmpty()); + + ic.addInPlayListener(listener); + ic.addInPlayListener(listener); + assertFalse(ic.isListenerListEmpty()); + + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(1, statuses.size()); + assertEquals(Status.WARN, statuses.get(0).getLevel()); + assertEquals("InPlayListener CountingListener has been already registered", statuses.get(0).getMessage()); + + ic.fireInPlay(null); + assertEquals(1, listener.count); + + assertTrue(ic.removeInPlayListener(listener)); + assertTrue(ic.isListenerListEmpty()); + assertFalse(ic.removeInPlayListener(listener)); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/spi/InterpreterTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/InterpreterTest.java new file mode 100644 index 000000000..a99c62c7d --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/InterpreterTest.java @@ -0,0 +1,215 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.spi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.Test; +import org.xml.sax.Attributes; +import org.xml.sax.helpers.AttributesImpl; +import org.xml.sax.helpers.LocatorImpl; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.joran.action.Action; +import ch.qos.logback.core.joran.event.BodyEvent; +import ch.qos.logback.core.joran.event.EndEvent; +import ch.qos.logback.core.joran.event.SaxEventRecorder; +import ch.qos.logback.core.joran.event.StartEvent; +import ch.qos.logback.core.status.Status; + +public class InterpreterTest { + + private final Context context = new ContextBase(); + private final SimpleRuleStore ruleStore = new SimpleRuleStore(context); + private final Interpreter interpreter = new Interpreter(context, ruleStore, new ElementPath()); + private final List calls = new ArrayList(); + + /** Records the calls it receives; optionally fails in begin() or body(). */ + class RecordingAction extends Action { + final String id; + RuntimeException beginFailure; + ActionException bodyFailure; + + RecordingAction(String id) { + this.id = id; + } + + @Override + public void begin(InterpretationContext ic, String name, Attributes attributes) { + calls.add(id + ".begin " + name); + if (beginFailure != null) { + throw beginFailure; + } + } + + @Override + public void body(InterpretationContext ic, String body) throws ActionException { + calls.add(id + ".body " + body); + if (bodyFailure != null) { + throw bodyFailure; + } + } + + @Override + public void end(InterpretationContext ic, String name) { + calls.add(id + ".end " + name); + } + + @Override + public String toString() { + return "RecordingAction(" + id + ")"; + } + } + + private static SaxEventRecorder recorder() { + SaxEventRecorder recorder = new SaxEventRecorder(); + recorder.setDocumentLocator(new LocatorImpl()); + return recorder; + } + + private static StartEvent startEvent(String name) { + SaxEventRecorder recorder = recorder(); + recorder.startElement("", name, name, new AttributesImpl()); + return (StartEvent) recorder.getSaxEventList().get(0); + } + + private static BodyEvent bodyEvent(String text) { + SaxEventRecorder recorder = recorder(); + recorder.characters(text.toCharArray(), 0, text.length()); + return (BodyEvent) recorder.getSaxEventList().get(0); + } + + private static EndEvent endEvent(String name) { + SaxEventRecorder recorder = recorder(); + recorder.endElement("", name, name); + return (EndEvent) recorder.getSaxEventList().get(0); + } + + private static LocatorImpl locatorAt(int line, int column) { + LocatorImpl locator = new LocatorImpl(); + locator.setLineNumber(line); + locator.setColumnNumber(column); + return locator; + } + + private List statuses() { + return context.getStatusManager().getCopyOfStatusList(); + } + + @Test + public void accessorsExposeTheCollaborators() { + assertSame(ruleStore, interpreter.getRuleStore()); + assertSame(interpreter.getInterpretationContext(), interpreter.getExecutionContext()); + assertSame(interpreter, interpreter.getInterpretationContext().getJoranInterpreter()); + assertNotNull(interpreter.getEventPlayer()); + } + + @Test + public void startDocumentChangesNothing() { + interpreter.startDocument(); + + assertNull(interpreter.getLocator()); + assertTrue(interpreter.actionListStack.isEmpty()); + assertTrue(interpreter.getInterpretationContext().isEmpty()); + assertEquals(0, context.getStatusManager().getCount()); + } + + @Test + public void tagNameFallsBackToQNameWhenLocalNameIsNullOrEmpty() { + assertEquals("local", interpreter.getTagName("local", "p:q")); + assertEquals("p:q", interpreter.getTagName(null, "p:q")); + assertEquals("p:q", interpreter.getTagName("", "p:q")); + } + + @Test + public void missingOrBlankBodyTextIsNotPassedToActions() { + ruleStore.addRule(new ElementSelector("x"), new RecordingAction("a")); + interpreter.startElement(startEvent("x")); + + BodyEvent withoutText = mock(BodyEvent.class); + when(withoutText.getText()).thenReturn(null); + BodyEvent blank = mock(BodyEvent.class); + when(blank.getText()).thenReturn(" "); + interpreter.characters(withoutText); + interpreter.characters(blank); + interpreter.characters(bodyEvent(" text ")); + + assertEquals(Arrays.asList("a.begin x", "a.body text"), calls); + } + + @Test + public void failingBodyActionIsReportedAndTheNextActionStillRuns() { + RecordingAction failing = new RecordingAction("a"); + ActionException failure = new ActionException(new IllegalArgumentException("bad body")); + failing.bodyFailure = failure; + ruleStore.addRule(new ElementSelector("x"), failing); + ruleStore.addRule(new ElementSelector("x"), new RecordingAction("b")); + + interpreter.startElement(startEvent("x")); + interpreter.characters(bodyEvent("text")); + interpreter.endElement(endEvent("x")); + + assertEquals(Arrays.asList("a.begin x", "b.begin x", "a.body text", "b.body text", "a.end x", "b.end x"), + calls); + List statuses = statuses(); + assertEquals(1, statuses.size()); + assertEquals(Status.ERROR, statuses.get(0).getLevel()); + assertEquals("Exception in end() methd for action [RecordingAction(a)]", statuses.get(0).getMessage()); + assertSame(failure, statuses.get(0).getThrowable()); + } + + @Test + public void nullActionListsAreIgnored() { + interpreter.callBeginAction(null, "x", new AttributesImpl()); + + interpreter.actionListStack.push(null); + interpreter.characters(bodyEvent("text")); + interpreter.endElement(endEvent("x")); + + assertTrue(interpreter.actionListStack.isEmpty()); + assertNull(interpreter.skip); + assertEquals(0, context.getStatusManager().getCount()); + } + + @Test + public void errorOriginCarriesLocatorPositionWhenKnown() { + RecordingAction failing = new RecordingAction("a"); + failing.beginFailure = new IllegalStateException("bad begin"); + List actions = Collections.singletonList(failing); + + interpreter.callBeginAction(actions, "x", new AttributesImpl()); + interpreter.setDocumentLocator(locatorAt(3, 5)); + interpreter.callBeginAction(actions, "x", new AttributesImpl()); + + List statuses = statuses(); + assertEquals(2, statuses.size()); + assertEquals("RuntimeException in Action for tag [x]", statuses.get(0).getMessage()); + assertEquals(Interpreter.class.getName() + "@NA:NA", statuses.get(0).getOrigin()); + assertEquals(Interpreter.class.getName() + "@3:5", statuses.get(1).getOrigin()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/spi/JoranExceptionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/JoranExceptionTest.java new file mode 100644 index 000000000..7a85d860c --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/JoranExceptionTest.java @@ -0,0 +1,40 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.spi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +import org.junit.Test; + +public class JoranExceptionTest { + + @Test + public void messageOnlyConstructorHasNoCause() { + JoranException e = new JoranException("bad config"); + assertEquals("bad config", e.getMessage()); + assertNull(e.getCause()); + } + + @Test + public void causeIsKept() { + Throwable cause = new IllegalStateException("root"); + JoranException e = new JoranException("bad config", cause); + assertEquals("bad config", e.getMessage()); + assertSame(cause, e.getCause()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/spi/NoAutoStartUtilTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/NoAutoStartUtilTest.java index 7bbad3a66..72a90f44e 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/spi/NoAutoStartUtilTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/NoAutoStartUtilTest.java @@ -16,6 +16,7 @@ package ch.qos.logback.core.joran.spi; import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertNotNull; import static junit.framework.Assert.assertTrue; import org.junit.Test; @@ -35,4 +36,15 @@ public void markedWithNoAutoStart() { DoNotAutoStart o = new DoNotAutoStart(); assertFalse(NoAutoStartUtil.notMarkedWithNoAutoStart(o)); } + + @Test + public void nullIsNeverAutoStarted() { + assertFalse(NoAutoStartUtil.notMarkedWithNoAutoStart(null)); + } + + @Test + public void canBeInstantiated() { + // the class only has static members but keeps its implicit public constructor + assertNotNull(new NoAutoStartUtil()); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/spi/SimpleRuleStoreTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/SimpleRuleStoreTest.java index 4b09a512e..143d4dcbb 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/spi/SimpleRuleStoreTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/SimpleRuleStoreTest.java @@ -17,6 +17,7 @@ import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertSame; import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertTrue; import static junit.framework.Assert.fail; @@ -26,8 +27,12 @@ import org.junit.Test; import org.xml.sax.Attributes; +import ch.qos.logback.core.Context; import ch.qos.logback.core.ContextBase; import ch.qos.logback.core.joran.action.Action; +import ch.qos.logback.core.joran.action.NOPAction; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.util.IncompatibleClassException; /** * Test SimpleRuleStore for various explicit rule combinations. @@ -215,6 +220,82 @@ public void testPrefixSuffixInteraction2() throws Exception { } } + @Test + public void addRuleByClassNameInstantiatesTheActionWithTheStoreContext() { + Context context = new ContextBase(); + SimpleRuleStore store = new SimpleRuleStore(context); + store.addRule(new ElementSelector("a"), NOPAction.class.getName()); + + List r = store.matchActions(new ElementPath("a")); + assertNotNull(r); + assertEquals(1, r.size()); + assertTrue(r.get(0) instanceof NOPAction); + assertSame(context, r.get(0).getContext()); + assertEquals(0, context.getStatusManager().getCount()); + } + + @Test + public void addRuleByClassNameReportsUninstantiableClassAndAddsNoRule() { + Context context = new ContextBase(); + SimpleRuleStore store = new SimpleRuleStore(context); + store.addRule(new ElementSelector("a"), String.class.getName()); + + assertNull(store.matchActions(new ElementPath("a"))); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(1, statuses.size()); + assertEquals(Status.ERROR, statuses.get(0).getLevel()); + assertEquals("Could not instantiate class [java.lang.String]", statuses.get(0).getMessage()); + assertTrue(statuses.get(0).getThrowable() instanceof IncompatibleClassException); + } + + @Test + public void middleMatchFindsSelectorContainedAnywhereInThePath() { + srs.addRule(new ElementSelector("*/b/*"), new XAction(1)); + + List r = srs.matchActions(new ElementPath("a/b/c")); + assertNotNull(r); + assertEquals(1, r.size()); + assertEquals(1, ((XAction) r.get(0)).id); + + assertNull(srs.matchActions(new ElementPath("a/x/c"))); + } + + @Test + public void longestMiddleMatchWins() { + srs.addRule(new ElementSelector("*/b/*"), new XAction(1)); + srs.addRule(new ElementSelector("*/b/c/*"), new XAction(2)); + srs.addRule(new ElementSelector("*/x/*"), new XAction(3)); + + List r = srs.matchActions(new ElementPath("a/b/c/d")); + assertNotNull(r); + assertEquals(1, r.size()); + assertEquals(2, ((XAction) r.get(0)).id); + } + + @Test + public void starStarSelectorDoesNotMatchOrdinaryPaths() { + // "*/*" has nothing between its two stars, so the middle part looked up + // in the path is "*/*" itself + srs.addRule(new ElementSelector("*/*"), new XAction(1)); + + assertNull(srs.matchActions(new ElementPath("a/b/c"))); + } + + @Test + public void loneKleeneStarMatchesNothing() { + srs.addRule(new ElementSelector("*"), new XAction(1)); + + assertNull(srs.matchActions(new ElementPath("a"))); + assertNull(srs.matchActions(new ElementPath("a/b"))); + } + + @Test + public void toStringListsTheRules() { + srs.addRule(new ElementSelector("a"), new XAction(7)); + + assertEquals("SimpleRuleStore ( rules = {[a]=[XAction(7)]} )", srs.toString()); + } + class XAction extends Action { int id = 0; From c78d0602e262828e1f147bbc17456304ac3a9127 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:13:52 +0000 Subject: [PATCH 10/79] test: kill surviving mutants in joran event/spi tests - SaxEventRecorderHandlerTest: verify the parser's validation feature is switched off; cover recordEvents(InputStream) and startDocument() with plain JVM tests so the changed SaxEventRecorder is fully covered without the Robolectric tests. - ConsoleTargetTest: check that flush() reaches the current System.out / System.err (dropping either flush call went unnoticed). - SimpleRuleStoreTest: selectors starred at only one end are not middle matches (dropping either star check in middleMatch went unnoticed). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../event/SaxEventRecorderHandlerTest.java | 25 ++++++++++++ .../core/joran/spi/ConsoleTargetTest.java | 38 ++++++++++++++++++- .../core/joran/spi/SimpleRuleStoreTest.java | 10 +++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/event/SaxEventRecorderHandlerTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/event/SaxEventRecorderHandlerTest.java index 1dd289a91..606fa3dc0 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/event/SaxEventRecorderHandlerTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/event/SaxEventRecorderHandlerTest.java @@ -27,12 +27,15 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import java.io.ByteArrayInputStream; import java.io.EOFException; import java.io.IOException; +import java.io.InputStream; import java.io.StringReader; import java.util.List; import org.junit.Test; +import org.mockito.ArgumentCaptor; import org.mockito.MockedConstruction; import org.xml.sax.InputSource; import org.xml.sax.Locator; @@ -233,6 +236,7 @@ public void recordEventsIgnoresUnsupportedValidationFeatureAndReturnsRecordedEve assertEquals(1, drivers.constructed().size()); Driver driver = drivers.constructed().get(0); + verify(driver).setFeature(VALIDATION_FEATURE, false); verify(driver).setFeature(NAMESPACES_FEATURE, true); verify(driver).setContentHandler(recorder); verify(driver).setErrorHandler(recorder); @@ -241,6 +245,27 @@ public void recordEventsIgnoresUnsupportedValidationFeatureAndReturnsRecordedEve } } + @Test + public void recordEventsFromStreamParsesThatStream() throws Exception { + InputStream in = new ByteArrayInputStream("".getBytes("UTF-8")); + try (MockedConstruction drivers = mockConstruction(Driver.class)) { + recorder.recordEvents(in); + + ArgumentCaptor source = ArgumentCaptor.forClass(InputSource.class); + verify(drivers.constructed().get(0)).parse(source.capture()); + assertSame(in, source.getValue().getByteStream()); + } + } + + @Test + public void startDocumentRecordsNothing() { + recorder.startDocument(); + + assertTrue(recorder.getSaxEventList().isEmpty()); + assertNull(recorder.getLocator()); + assertTrue(statuses().isEmpty()); + } + @Test public void parserConfigurationFailureIsReportedAndNothingIsParsed() throws Exception { SAXNotRecognizedException failure = new SAXNotRecognizedException("no namespaces"); diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/spi/ConsoleTargetTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/ConsoleTargetTest.java index cdc3e2e0c..9d782ffe4 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/spi/ConsoleTargetTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/ConsoleTargetTest.java @@ -19,6 +19,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -32,17 +33,34 @@ @SuppressWarnings("deprecation") public class ConsoleTargetTest { + /** A console stand-in that counts how often it is flushed. */ + private static class FlushCountingPrintStream extends PrintStream { + int flushes; + + FlushCountingPrintStream(OutputStream out) { + super(out); + } + + @Override + public void flush() { + flushes++; + super.flush(); + } + } + private PrintStream originalOut; private PrintStream originalErr; private final ByteArrayOutputStream capturedOut = new ByteArrayOutputStream(); private final ByteArrayOutputStream capturedErr = new ByteArrayOutputStream(); + private final FlushCountingPrintStream out = new FlushCountingPrintStream(capturedOut); + private final FlushCountingPrintStream err = new FlushCountingPrintStream(capturedErr); @Before public void redirectConsole() { originalOut = System.out; originalErr = System.err; - System.setOut(new PrintStream(capturedOut)); - System.setErr(new PrintStream(capturedErr)); + System.setOut(out); + System.setErr(err); } @After @@ -76,6 +94,22 @@ public void systemErrStreamWritesToTheCurrentSystemErr() throws IOException { assertEquals(0, capturedOut.size()); } + @Test + public void systemOutStreamFlushesTheCurrentSystemOut() throws IOException { + int before = out.flushes; + ConsoleTarget.SystemOut.getStream().flush(); + + assertTrue(out.flushes > before); + } + + @Test + public void systemErrStreamFlushesTheCurrentSystemErr() throws IOException { + int before = err.flushes; + ConsoleTarget.SystemErr.getStream().flush(); + + assertTrue(err.flushes > before); + } + @Test public void findByNameIgnoresCase() { assertSame(ConsoleTarget.SystemOut, ConsoleTarget.findByName("System.out")); diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/spi/SimpleRuleStoreTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/SimpleRuleStoreTest.java index 143d4dcbb..6c3fe2a03 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/spi/SimpleRuleStoreTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/spi/SimpleRuleStoreTest.java @@ -272,6 +272,16 @@ public void longestMiddleMatchWins() { assertEquals(2, ((XAction) r.get(0)).id); } + @Test + public void selectorsStarredAtOneEndOnlyAreNotMiddleMatches() { + // "x/b/*" and "*/b/y" both contain "b", but only a selector starred at + // both ends matches a part found anywhere in the path + srs.addRule(new ElementSelector("x/b/*"), new XAction(1)); + srs.addRule(new ElementSelector("*/b/y"), new XAction(2)); + + assertNull(srs.matchActions(new ElementPath("a/b/c"))); + } + @Test public void starStarSelectorDoesNotMatchOrdinaryPaths() { // "*/*" has nothing between its two stars, so the middle part looked up From d2711bcda8a247dac603683a9f83ed05a6b6a97f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:54:22 +0000 Subject: [PATCH 11/79] fix: remove a dir emptied by archive cleanup in the same pass TimeBasedArchiveRemover.findEmptyDirs() walks the matching directories deepest first so that a directory whose only child is an empty directory can be deleted right after that child. The check compared the directory with the previously found empty directory itself instead of with that directory's parent, so it could only match a duplicate entry: such a parent was never deleted in the pass that emptied it and lingered until a later cleanup. Compare with the parent of the last empty directory found. The new test fails without the fix (the emptied parent is not deleted) and also exercises the single-child case that must not match. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../helper/TimeBasedArchiveRemover.java | 5 +- .../helper/TimeBasedArchiveRemoverTest.java | 48 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/logback-android/src/main/java/ch/qos/logback/core/rolling/helper/TimeBasedArchiveRemover.java b/logback-android/src/main/java/ch/qos/logback/core/rolling/helper/TimeBasedArchiveRemover.java index a6a9c5e3b..d681f8d61 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/rolling/helper/TimeBasedArchiveRemover.java +++ b/logback-android/src/main/java/ch/qos/logback/core/rolling/helper/TimeBasedArchiveRemover.java @@ -195,7 +195,10 @@ private List findEmptyDirs() { ArrayDeque emptyDirs = new ArrayDeque(); for (String dir : dirList) { int childSize = this.fileProvider.list(new File(dir), null).length; - if (childSize == 0 || (childSize == 1 && emptyDirs.size() > 0 && dir.equals(emptyDirs.peekLast()))) { + // a directory whose only child is the empty directory found just before + // it (so deleting that child empties it) is deleted too + if (childSize == 0 || (childSize == 1 && emptyDirs.size() > 0 + && dir.equals(new File(emptyDirs.peekLast()).getParent()))) { emptyDirs.add(dir); } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/TimeBasedArchiveRemoverTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/TimeBasedArchiveRemoverTest.java index 6b482433e..96c6c09d4 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/TimeBasedArchiveRemoverTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/TimeBasedArchiveRemoverTest.java @@ -21,18 +21,24 @@ import org.junit.rules.TemporaryFolder; import java.io.File; +import java.io.FilenameFilter; import java.io.IOException; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Arrays; +import java.util.Collections; import java.util.Date; import java.util.Locale; import java.util.TimeZone; import ch.qos.logback.classic.LoggerContext; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; @@ -135,6 +141,48 @@ public void keepsRecentFilesAndOlderFilesWithinTotalSizeCap() { } } + @Test + public void removesEmptyParentOfARemovedEmptyDirInTheSamePass() throws IOException { + File emptyMonth = tmpDir.newFolder("nested_2017", "05"); + File emptyYear = emptyMonth.getParentFile(); + File nonEmptyMonth = tmpDir.newFolder("nested_2018", "03"); + File unrelatedFile = new File(nonEmptyMonth, "keep.txt"); + assertTrue(unrelatedFile.createNewFile()); + File nonEmptyYear = nonEmptyMonth.getParentFile(); + // list directory entries in descending name order, so the directories + // are visited (deepest first) as nested_2017/05, nested_2017, + // nested_2018/03, nested_2018: the latter two each have a single child + // that is not the directory found empty just before + doAnswer(invocation -> { + File[] files = (File[]) invocation.callRealMethod(); + if (files != null) { + Arrays.sort(files, Collections.reverseOrder()); + } + return files; + }).when(fileProvider).listFiles(any(File.class), isNull(FilenameFilter.class)); + TimeBasedArchiveRemover gmtRemover = newGmtArchiveRemover( + "nested_%d{yyyy/MM, " + TIMEZONE_NAME + ", aux}/app_%d{" + DATE_FORMAT + ", " + TIMEZONE_NAME + "}.log"); + + gmtRemover.clean(EXPIRY); + + verify(fileProvider).deleteFile(emptyMonth); + verify(fileProvider).deleteFile(emptyYear); + assertFalse(emptyMonth.exists()); + assertFalse("a directory emptied by the removal of its only child must be removed too", emptyYear.exists()); + verify(fileProvider, never()).deleteFile(nonEmptyMonth); + verify(fileProvider, never()).deleteFile(nonEmptyYear); + assertTrue(unrelatedFile.exists()); + } + + private TimeBasedArchiveRemover newGmtArchiveRemover(String relativePattern) { + LoggerContext context = new LoggerContext(); + RollingCalendar rollingCalendar = new RollingCalendar(DATE_FORMAT, TimeZone.getTimeZone(TIMEZONE_NAME), Locale.US); + FileNamePattern filePattern = new FileNamePattern(tmpDir.getRoot().getAbsolutePath() + File.separator + relativePattern, context); + TimeBasedArchiveRemover archiveRemover = new TimeBasedArchiveRemover(filePattern, rollingCalendar, fileProvider); + archiveRemover.setContext(context); + return archiveRemover; + } + private void setupSizeCapTest() { final long FILE_SIZE = 1024L; // XXX: Need to use doReturn().when() here to avoid NPE From b190678993ee1ad31919944c7a2801ada84ddf9b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:54:59 +0000 Subject: [PATCH 12/79] refactor: fold Compressor's unreachable enum-switch defaults into NONE compress() and computeFileNameStrWithoutCompSuffix() switch over every CompressionMode constant without a default, so javac emits an implicit default branch (and, in the latter, a trailing throw of IllegalStateException) that no CompressionMode value can reach, which keeps the classes below full branch coverage. Make NONE share a default label with the code it already ran; behavior is unchanged for every mode (NONE still throws UnsupportedOperationException from compress() and leaves names unchanged in computeFileNameStrWithoutCompSuffix()). The added tests pin that behavior for all three modes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../core/rolling/helper/Compressor.java | 3 +- .../core/rolling/helper/CompressorTest.java | 86 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/rolling/helper/CompressorTest.java diff --git a/logback-android/src/main/java/ch/qos/logback/core/rolling/helper/Compressor.java b/logback-android/src/main/java/ch/qos/logback/core/rolling/helper/Compressor.java index 74ac5b57e..94248c3e7 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/rolling/helper/Compressor.java +++ b/logback-android/src/main/java/ch/qos/logback/core/rolling/helper/Compressor.java @@ -73,6 +73,7 @@ public void compress(String nameOfFile2Compress, String nameOfCompressedFile, St zipCompress(nameOfFile2Compress, nameOfCompressedFile, innerEntryName); break; case NONE: + default: throw new UnsupportedOperationException( "compress method called in NONE compression mode"); } @@ -312,9 +313,9 @@ static public String computeFileNameStrWithoutCompSuffix(String fileNamePatternS else return fileNamePatternStr; case NONE: + default: return fileNamePatternStr; } - throw new IllegalStateException("Execution should not reach this point"); } void createMissingTargetDirsIfNecessary(File file) { diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/CompressorTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/CompressorTest.java new file mode 100644 index 000000000..9aa4b907c --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/CompressorTest.java @@ -0,0 +1,86 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.rolling.helper; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; + +public class CompressorTest { + + private static final String CONTENT = "some log data\n"; + + @Rule + public TemporaryFolder tmpDir = new TemporaryFolder(); + + private final Context context = new ContextBase(); + + private File source; + + @Before + public void setUp() throws IOException { + source = tmpDir.newFile("app.log"); + write(source, CONTENT); + } + + @Test + public void compressingInNoneModeIsUnsupported() { + Compressor compressor = compressor(CompressionMode.NONE); + + UnsupportedOperationException e = assertThrows(UnsupportedOperationException.class, + () -> compressor.compress(source.getPath(), source.getPath() + ".gz", "app.log")); + + assertEquals("compress method called in NONE compression mode", e.getMessage()); + assertTrue(source.exists()); + } + + @Test + public void fileNameWithoutCompressionSuffixIsReturnedUnchanged() { + assertEquals("app.log", Compressor.computeFileNameStrWithoutCompSuffix("app.log.gz", CompressionMode.GZ)); + assertEquals("app.log", Compressor.computeFileNameStrWithoutCompSuffix("app.log", CompressionMode.GZ)); + assertEquals("app.log.zip", Compressor.computeFileNameStrWithoutCompSuffix("app.log.zip", CompressionMode.GZ)); + assertEquals("app.log", Compressor.computeFileNameStrWithoutCompSuffix("app.log.zip", CompressionMode.ZIP)); + assertEquals("app.log.gz", Compressor.computeFileNameStrWithoutCompSuffix("app.log.gz", CompressionMode.ZIP)); + assertEquals("app.log.gz", Compressor.computeFileNameStrWithoutCompSuffix("app.log.gz", CompressionMode.NONE)); + } + + private Compressor compressor(CompressionMode mode) { + Compressor compressor = new Compressor(mode); + compressor.setContext(context); + return compressor; + } + + private static void write(File file, String content) throws IOException { + FileOutputStream fos = new FileOutputStream(file); + try { + fos.write(content.getBytes("UTF-8")); + } finally { + fos.close(); + } + } +} From 7e6264057a67f431a9e12449045eb9039118e5e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:56:23 +0000 Subject: [PATCH 13/79] test: cover core.rolling.helper to 100% of lines and branches Pins the remaining behavior of the rolling helpers: - Compressor: argument checks (missing source, missing ZIP entry name, existing archive), failures to create the target directory, to rename the temp archive, to delete the temp file or the source (fault injection through synchronous status listeners), stale temp files of unknown age or that cannot be deleted, and the quiet close/sweep helpers. - RenameUtil: same source and target, missing source, uncreatable target directory, failed rename on the same volume vs. the copy fallback across volumes, every outcome of areOnDifferentVolumes (pre-Java 7, no or missing parent, failed store lookup, same/different store) and renameByCopying including an undeletable source. - TimeBasedArchiveRemover: undeletable files, total size cap that only counts deleted files, a non-positive cap, and (deterministically) that an empty directory of a retained period is kept. - FileNamePattern (parse errors, equals/hashCode, null pattern, auxiliary dates and foreign converters in regexes), RollingCalendar (erroneous and half-day periodicity, weekly periods, printed periodicity), FileFinder, FileFilterUtil, FileStoreUtil, FileSorter, DateParser, IntParser, the token converters and DefaultFileProvider. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../core/rolling/helper/CompressorTest.java | 272 +++++++++++++++++ .../core/rolling/helper/DateParserTest.java | 72 +++++ .../helper/DateTokenConverterTest.java | 58 ++++ .../helper/DefaultFileProviderTest.java | 48 +++ .../rolling/helper/FileFilterUtilTest.java | 86 ++++++ .../core/rolling/helper/FileFinderTest.java | 104 +++++++ .../rolling/helper/FileNamePatternTest.java | 102 +++++++ .../core/rolling/helper/FileSorterTest.java | 20 ++ .../rolling/helper/FileStoreUtilTest.java | 54 ++++ .../core/rolling/helper/IntParserTest.java | 50 ++++ .../helper/IntegerTokenConverterTest.java | 47 +++ .../core/rolling/helper/RenameUtilTest.java | 283 ++++++++++++++++++ .../rolling/helper/RollingCalendarTest.java | 76 +++++ .../helper/TimeBasedArchiveRemoverTest.java | 96 ++++++ .../rolling/helper/TokenConverterTest.java | 47 +++ 15 files changed, 1415 insertions(+) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/rolling/helper/DateParserTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/rolling/helper/DateTokenConverterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/rolling/helper/DefaultFileProviderTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileFilterUtilTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/rolling/helper/IntParserTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/rolling/helper/IntegerTokenConverterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/rolling/helper/RenameUtilTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/rolling/helper/TokenConverterTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/CompressorTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/CompressorTest.java index 9aa4b907c..16bf65082 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/CompressorTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/CompressorTest.java @@ -16,12 +16,19 @@ package ch.qos.logback.core.rolling.helper; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import java.io.ByteArrayOutputStream; +import java.io.Closeable; import java.io.File; +import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.util.zip.GZIPInputStream; import org.junit.Before; import org.junit.Rule; @@ -30,6 +37,8 @@ import ch.qos.logback.core.Context; import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusListener; public class CompressorTest { @@ -59,6 +68,200 @@ public void compressingInNoneModeIsUnsupported() { assertTrue(source.exists()); } + @Test + public void gzCompressionOfMissingFileOnlyWarns() { + File missing = new File(tmpDir.getRoot(), "missing.log"); + File target = new File(tmpDir.getRoot(), "missing.log.gz"); + + compressor(CompressionMode.GZ).compress(missing.getPath(), target.getPath(), null); + + assertTrue(hasStatus(Status.WARN, "The file to compress named [" + missing.getPath() + "] does not exist.")); + assertFalse(target.exists()); + assertFalse(hasStatusStartingWith(Status.INFO, "GZ compressing")); + } + + @Test + public void zipCompressionOfMissingFileOnlyWarns() { + File missing = new File(tmpDir.getRoot(), "missing.log"); + File target = new File(tmpDir.getRoot(), "missing.log.zip"); + + compressor(CompressionMode.ZIP).compress(missing.getPath(), target.getPath(), "missing.log"); + + assertTrue(hasStatus(Status.WARN, "The file to compress named [" + missing.getPath() + "] does not exist.")); + assertFalse(target.exists()); + assertFalse(hasStatusStartingWith(Status.INFO, "ZIP compressing")); + } + + @Test + public void zipCompressionWithoutInnerEntryNameOnlyWarns() throws IOException { + File target = new File(tmpDir.getRoot(), "app.log.zip"); + + compressor(CompressionMode.ZIP).compress(source.getPath(), target.getPath(), null); + + assertTrue(hasStatus(Status.WARN, "The innerEntryName parameter cannot be null")); + assertFalse(target.exists()); + assertEquals(CONTENT, read(source)); + } + + @Test + public void gzCompressionDoesNotOverwriteExistingArchive() throws IOException { + File target = tmpDir.newFile("app.log.gz"); + write(target, "previous archive"); + + compressor(CompressionMode.GZ).compress(source.getPath(), target.getPath(), null); + + assertTrue(hasStatus(Status.WARN, "The target compressed file named [" + target.getPath() + + "] exist already. Aborting file compression.")); + assertEquals("previous archive", read(target)); + assertEquals(CONTENT, read(source)); + } + + @Test + public void zipCompressionDoesNotOverwriteExistingArchive() throws IOException { + File target = tmpDir.newFile("app.log.zip"); + write(target, "previous archive"); + + compressor(CompressionMode.ZIP).compress(source.getPath(), target.getPath(), "app.log"); + + assertTrue(hasStatus(Status.WARN, "The target compressed file named [" + target.getPath() + "] exist already.")); + assertEquals("previous archive", read(target)); + assertEquals(CONTENT, read(source)); + } + + @Test + public void failureToCreateTargetDirectoryIsReportedAndSourceIsKept() throws IOException { + // the target's grandparent is a regular file, so its parent can't be created + File blocker = tmpDir.newFile("blocker"); + File target = new File(new File(blocker, "sub"), "app.log.gz"); + + compressor(CompressionMode.GZ).compress(source.getPath(), target.getPath(), null); + + assertTrue(hasStatus(Status.ERROR, "Failed to create parent directories for [" + target.getAbsolutePath() + "]")); + assertTrue(hasStatusStartingWith(Status.ERROR, "Error occurred while compressing")); + assertFalse(target.exists()); + assertEquals(CONTENT, read(source)); + } + + @Test + public void failureToRenameTempArchiveKeepsSourceAndRemovesTempFile() throws IOException { + final File target = new File(tmpDir.getRoot(), "app.log.gz"); + File tmpFile = new File(target.getPath() + Compressor.TMP_SUFFIX); + // right after the existence check of the target, something else occupies + // its name with a directory, so the final rename of the temp file fails + onStatus("GZ compressing", () -> assertTrue(target.mkdir())); + + compressor(CompressionMode.GZ).compress(source.getPath(), target.getPath(), null); + + assertTrue(hasStatus(Status.ERROR, "Failed to rename compressed file [" + tmpFile + "] to [" + target + "].")); + assertFalse(hasStatusStartingWith(Status.INFO, "Done compressing")); + assertFalse("temp file must be removed", tmpFile.exists()); + assertFalse("no warning expected for a deletable temp file", hasStatusStartingWith(Status.WARN, "Could not delete")); + assertTrue(target.isDirectory()); + assertEquals(CONTENT, read(source)); + } + + @Test + public void undeletableTempFileIsReported() throws IOException { + File target = new File(tmpDir.getRoot(), "app.log.gz"); + // a (fresh, so not swept) non-empty directory in place of the temp file: + // it can neither be written nor deleted + File tmpFile = new File(target.getPath() + Compressor.TMP_SUFFIX); + assertTrue(tmpFile.mkdir()); + assertTrue(new File(tmpFile, "child").createNewFile()); + + compressor(CompressionMode.GZ).compress(source.getPath(), target.getPath(), null); + + assertTrue(hasStatusStartingWith(Status.ERROR, "Error occurred while compressing")); + assertTrue(hasStatus(Status.WARN, "Could not delete temporary file [" + tmpFile + "].")); + assertFalse(target.exists()); + assertEquals(CONTENT, read(source)); + } + + @Test + public void undeletableSourceFileIsReportedAfterSuccessfulCompression() throws IOException { + final File target = new File(tmpDir.getRoot(), "app.log.gz"); + // once compressed, the source path is replaced with a non-empty directory + // so that deleting it fails + onStatus("Done compressing", () -> { + assertTrue(source.delete()); + assertTrue(source.mkdir()); + assertTrue(new File(source, "child").createNewFile()); + }); + + compressor(CompressionMode.GZ).compress(source.getPath(), target.getPath(), null); + + assertTrue(hasStatus(Status.WARN, "Could not delete [" + source + "].")); + assertTrue(source.isDirectory()); + assertEquals(CONTENT, gunzip(target)); + } + + @Test + public void staleTempFileWithUnknownModificationTimeIsKept() throws IOException { + File unknownAge = tmpDir.newFile("other.log.gz" + Compressor.TMP_SUFFIX); + // File#lastModified reports 0 when the time is unknown + assertTrue(unknownAge.setLastModified(0)); + assertEquals(0, unknownAge.lastModified()); + File target = new File(tmpDir.getRoot(), "app.log.gz"); + + compressor(CompressionMode.GZ).compress(source.getPath(), target.getPath(), null); + + assertTrue(unknownAge.exists()); + assertFalse(hasStatusStartingWith(Status.INFO, "Deleted stale temporary file")); + assertEquals(CONTENT, gunzip(target)); + } + + @Test + public void staleTempFileThatCannotBeDeletedIsNotReportedAsDeleted() throws IOException { + File undeletable = tmpDir.newFolder("other.log.zip" + Compressor.TMP_SUFFIX); + assertTrue(new File(undeletable, "child").createNewFile()); + assertTrue(undeletable.setLastModified(System.currentTimeMillis() - Compressor.STALE_TMP_AGE_MS - 60_000)); + File target = new File(tmpDir.getRoot(), "app.log.gz"); + + compressor(CompressionMode.GZ).compress(source.getPath(), target.getPath(), null); + + assertTrue(undeletable.exists()); + assertFalse(hasStatusStartingWith(Status.INFO, "Deleted stale temporary file")); + assertEquals(CONTENT, gunzip(target)); + } + + @Test + public void staleTempFileSweepIgnoresTargetWithoutParentDirectory() throws Exception { + Compressor compressor = compressor(CompressionMode.GZ); + Method deleteStaleTempFiles = Compressor.class.getDeclaredMethod("deleteStaleTempFiles", File.class); + deleteStaleTempFiles.setAccessible(true); + File root = new File(File.separator).getAbsoluteFile(); + + // the root directory has no parent to sweep + deleteStaleTempFiles.invoke(compressor, root); + + assertEquals(0, context.getStatusManager().getCount()); + } + + @Test + public void closeQuietlySwallowsCloseFailure() throws Exception { + Compressor compressor = compressor(CompressionMode.GZ); + Method closeQuietly = Compressor.class.getDeclaredMethod("closeQuietly", Closeable.class); + closeQuietly.setAccessible(true); + final boolean[] closed = { false }; + Closeable failing = () -> { + closed[0] = true; + throw new IOException("close failed"); + }; + + closeQuietly.invoke(compressor, failing); + + assertTrue(closed[0]); + assertEquals(0, context.getStatusManager().getCount()); + } + + @Test + public void computeZipEntryNamesEntryAfterArchiveWithoutZipSuffix() { + Compressor compressor = compressor(CompressionMode.ZIP); + + assertEquals("app-2019-11-04.log", compressor.computeZipEntry(new File("logs", "app-2019-11-04.log.zip")).getName()); + assertEquals("app.log", compressor.computeZipEntry(new File("logs", "app.log")).getName()); + } + @Test public void fileNameWithoutCompressionSuffixIsReturnedUnchanged() { assertEquals("app.log", Compressor.computeFileNameStrWithoutCompSuffix("app.log.gz", CompressionMode.GZ)); @@ -75,6 +278,47 @@ private Compressor compressor(CompressionMode mode) { return compressor; } + private interface IoAction { + void run() throws IOException; + } + + /** + * Runs the action (synchronously, in the compressing thread) when the + * compressor reports a status whose message starts with the given prefix. + */ + private void onStatus(final String messagePrefix, final IoAction action) { + context.getStatusManager().add(new StatusListener() { + @Override + public void addStatusEvent(Status status) { + if (status.getMessage().startsWith(messagePrefix)) { + try { + action.run(); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + } + }); + } + + private boolean hasStatus(int level, String message) { + for (Status s : context.getStatusManager().getCopyOfStatusList()) { + if (s.getLevel() == level && message.equals(s.getMessage())) { + return true; + } + } + return false; + } + + private boolean hasStatusStartingWith(int level, String prefix) { + for (Status s : context.getStatusManager().getCopyOfStatusList()) { + if (s.getLevel() == level && s.getMessage().startsWith(prefix)) { + return true; + } + } + return false; + } + private static void write(File file, String content) throws IOException { FileOutputStream fos = new FileOutputStream(file); try { @@ -83,4 +327,32 @@ private static void write(File file, String content) throws IOException { fos.close(); } } + + private static String read(File file) throws IOException { + InputStream in = new FileInputStream(file); + try { + return readAll(in); + } finally { + in.close(); + } + } + + private static String gunzip(File gzFile) throws IOException { + InputStream in = new GZIPInputStream(new FileInputStream(gzFile)); + try { + return readAll(in); + } finally { + in.close(); + } + } + + private static String readAll(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[1024]; + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + } + return out.toString("UTF-8"); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/DateParserTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/DateParserTest.java new file mode 100644 index 000000000..d3a8a57b1 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/DateParserTest.java @@ -0,0 +1,72 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.rolling.helper; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; + +public class DateParserTest { + + private final Context context = new ContextBase(); + + @Test + public void parsesPrimaryDateOfMatchingFilename() throws ParseException { + DateParser parser = new DateParser(new FileNamePattern("/logs/app-%d{yyyy-MM-dd, GMT}.log", context)); + + assertEquals(parse("yyyy-MM-dd", TimeZone.getTimeZone("GMT"), "2019-11-04"), + parser.parseFilename("/logs/app-2019-11-04.log")); + } + + @Test + public void filenameNotMatchingThePatternHasNoDate() { + DateParser parser = new DateParser(new FileNamePattern("/logs/app-%d{yyyy-MM-dd, GMT}.log", context)); + + assertNull(parser.parseFilename("/logs/other.txt")); + } + + @Test + public void patternWithoutPrimaryDateYieldsNoDate() { + DateParser parser = new DateParser(new FileNamePattern("/logs/app-%i.log", context)); + + // the filename matches, but there is no date token to capture + assertNull(parser.parseFilename("/logs/app-1.log")); + } + + @Test + public void patternWithoutPrimaryDateParsesWithDefaultDatePatternAndTimeZone() throws ParseException { + DateParser parser = new DateParser(new FileNamePattern("/logs/app-%d{yyyy/MM, aux}-%i.log", context)); + + assertEquals(parse(DateTokenConverter.DEFAULT_DATE_PATTERN, TimeZone.getDefault(), "2019-11-04"), + parser.parseDate("2019-11-04")); + } + + private static Date parse(String datePattern, TimeZone timeZone, String date) throws ParseException { + SimpleDateFormat format = new SimpleDateFormat(datePattern, Locale.US); + format.setTimeZone(timeZone); + return format.parse(date); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/DateTokenConverterTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/DateTokenConverterTest.java new file mode 100644 index 000000000..1050ef31c --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/DateTokenConverterTest.java @@ -0,0 +1,58 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.rolling.helper; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import java.util.Arrays; +import java.util.Date; + +import org.junit.Before; +import org.junit.Test; + +public class DateTokenConverterTest { + + private DateTokenConverter converter; + + @Before + public void setUp() { + converter = new DateTokenConverter(); + converter.setOptionList(Arrays.asList("yyyy-MM-dd", "GMT")); + converter.start(); + } + + @Test + public void convertsDate() { + assertEquals("1970-01-02", converter.convert((Object) new Date(24L * 60 * 60 * 1000))); + } + + @Test + public void convertingNullIsForbidden() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> converter.convert((Object) null)); + + assertEquals("Null argument forbidden", e.getMessage()); + } + + @Test + public void convertingNonDateIsRejected() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> converter.convert((Object) Integer.valueOf(3))); + + assertEquals("Cannot convert 3 of typejava.lang.Integer", e.getMessage()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/DefaultFileProviderTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/DefaultFileProviderTest.java new file mode 100644 index 000000000..da3188d39 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/DefaultFileProviderTest.java @@ -0,0 +1,48 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.rolling.helper; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class DefaultFileProviderTest { + + @Rule + public TemporaryFolder tmpDir = new TemporaryFolder(); + + private final DefaultFileProvider fileProvider = new DefaultFileProvider(); + + @Test + public void existsReflectsTheFileSystem() throws IOException { + File file = tmpDir.newFile("app.log"); + File dir = tmpDir.newFolder("logs"); + File missing = new File(tmpDir.getRoot(), "missing.log"); + + assertTrue(fileProvider.exists(file)); + assertTrue(fileProvider.exists(dir)); + assertFalse(fileProvider.exists(missing)); + + assertTrue(fileProvider.deleteFile(file)); + assertFalse(fileProvider.exists(file)); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileFilterUtilTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileFilterUtilTest.java new file mode 100644 index 000000000..0a9c77692 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileFilterUtilTest.java @@ -0,0 +1,86 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.rolling.helper; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; + +import java.io.File; +import java.io.IOException; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** + * Tests {@link FileFilterUtil} from within its package (see also + * ch.qos.logback.core.helpers.FileFilterUtilTest). + */ +public class FileFilterUtilTest { + + private static final String STEM_REGEX = "app\\.(\\d+)\\.log"; + + @Rule + public TemporaryFolder tmpDir = new TemporaryFolder(); + + @Test + public void isInstantiable() { + // only static members, but the class is public and has a public constructor + assertNotNull(new FileFilterUtil()); + } + + @Test + public void listsOnlyFilesMatchingTheStemRegex() throws IOException { + File match = tmpDir.newFile("app.1.log"); + tmpDir.newFile("other.log"); + + assertArrayEquals(new File[] { match }, FileFilterUtil.filesInFolderMatchingStemRegex(tmpDir.getRoot(), STEM_REGEX)); + } + + @Test + public void nullFolderHasNoMatchingFiles() { + assertEquals(0, FileFilterUtil.filesInFolderMatchingStemRegex(null, STEM_REGEX).length); + } + + @Test + public void missingFolderHasNoMatchingFiles() { + File missing = new File(tmpDir.getRoot(), "missing"); + + assertEquals(0, FileFilterUtil.filesInFolderMatchingStemRegex(missing, STEM_REGEX).length); + } + + @Test + public void regularFileHasNoMatchingFiles() throws IOException { + File notAFolder = tmpDir.newFile("app.2.log"); + + assertEquals(0, FileFilterUtil.filesInFolderMatchingStemRegex(notAFolder, STEM_REGEX).length); + } + + @Test + public void extractsCounterOfMatchingFile() { + assertEquals(12, FileFilterUtil.extractCounter(new File("logs", "app.12.log"), STEM_REGEX)); + } + + @Test + public void extractingCounterOfNonMatchingFileFails() { + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> FileFilterUtil.extractCounter(new File("logs", "other.log"), STEM_REGEX)); + + assertEquals("The regex [" + STEM_REGEX + "] should match [other.log]", e.getMessage()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileFinderTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileFinderTest.java index 188f7d3da..fb906ca06 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileFinderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileFinderTest.java @@ -23,10 +23,17 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.io.File; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -79,6 +86,90 @@ private void setupTmpDir(TemporaryFolder tmpDir) throws IOException { } } + public static class FindAmongOtherEntries { + File matchingDir; + File matchingSubDir; + File matchingFile; + + @Rule + public TemporaryFolder tmpDir = new TemporaryFolder(); + + @Before + public void setup() throws IOException { + matchingSubDir = tmpDir.newFolder("2019", "11"); + matchingDir = matchingSubDir.getParentFile(); + matchingFile = tmpDir.newFile("2019/11/app_20191101.log"); + // a directory not matching its path part, holding a matching file + tmpDir.newFolder("abcd", "11"); + tmpDir.newFile("abcd/11/app_20191102.log"); + // a regular file matching a directory's path part + tmpDir.newFile("2020"); + } + + @Test + public void findsFilesOnlyThroughMatchingDirectories() { + FileFinder finder = new FileFinder(new DefaultFileProvider()); + + List actualFiles = finder.findFiles(pathPattern(tmpDir.getRoot())); + + assertThat(actualFiles, contains(matchingFile.getAbsolutePath())); + } + + @Test + public void findsOnlyMatchingDirectories() { + FileFinder finder = new FileFinder(new DefaultFileProvider()); + + List actualDirs = finder.findDirs(pathPattern(tmpDir.getRoot())); + + assertThat(actualDirs, contains(matchingDir.getAbsolutePath(), matchingSubDir.getAbsolutePath())); + } + + @Test + public void findsNothingUnderMissingBaseDirectory() { + FileFinder finder = new FileFinder(new DefaultFileProvider()); + File missingBaseDir = new File(tmpDir.getRoot(), "missing"); + + assertThat(finder.findFiles(pathPattern(missingBaseDir)), empty()); + assertThat(finder.findDirs(pathPattern(missingBaseDir)), empty()); + } + + private String pathPattern(File baseDir) { + return baseDir + File.separator + FileFinder.regexEscapePath("\\d{4}/\\d{2}/app_\\d{8}.log"); + } + } + + public static class ListPathPart { + + @Test + public void literalPartListsItsOwnPath() { + File baseDir = new File("logs").getAbsoluteFile(); + File[] children = { new File(baseDir, "2019") }; + FileProvider fileProvider = mock(FileProvider.class); + when(fileProvider.listFiles(baseDir, null)).thenReturn(children); + + assertEquals(Arrays.asList(children), new LiteralPathPart("logs").listFiles(fileProvider)); + } + + @Test + public void regexPartListsWorkingDirectory() { + File workingDir = new File(".").getAbsoluteFile(); + File[] children = { new File(workingDir, "2019"), new File(workingDir, "notes.txt") }; + FileProvider fileProvider = mock(FileProvider.class); + when(fileProvider.listFiles(workingDir, null)).thenReturn(children); + + assertEquals(Arrays.asList(children), new RegexPathPart("\\d{4}").listFiles(fileProvider)); + verify(fileProvider).listFiles(workingDir, null); + } + + @Test + public void unlistablePathHasNoFiles() { + FileProvider fileProvider = mock(FileProvider.class); + when(fileProvider.listFiles(new File("logs").getAbsoluteFile(), null)).thenReturn(null); + + assertTrue(new LiteralPathPart("logs").listFiles(fileProvider).isEmpty()); + } + } + public static class SplitPath { FileFinder finder; @@ -120,6 +211,19 @@ public void splitsPathOfEscapedRegex() { } } + @Test + public void partMarkedOnlyAsRegexStartIsLiteral() { + // a regex part needs both the start and the end marker + String halfMarked = "/logs/(?:\uFFFE)?\\d+/c.log"; + + List parts = finder.splitPath(halfMarked); + + assertEquals(1, parts.size()); + assertTrue(parts.get(0) instanceof LiteralPathPart); + // the marker is removed, the rest is kept verbatim + assertEquals("/logs/\\d+/c.log", parts.get(0).part.replace(File.separator, "/")); + } + private List splitPath(String pattern) { List parts = new ArrayList(); for (PathPart p : finder.splitPath(pattern)) { diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileNamePatternTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileNamePatternTest.java index 7f6292b63..4a9bea7da 100755 --- a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileNamePatternTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileNamePatternTest.java @@ -16,16 +16,25 @@ package ch.qos.logback.core.rolling.helper; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import java.util.Calendar; +import java.util.Date; +import java.util.List; import java.util.TimeZone; +import java.util.regex.Pattern; import org.junit.Test; import ch.qos.logback.core.Context; import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.pattern.Converter; +import ch.qos.logback.core.pattern.LiteralConverter; +import ch.qos.logback.core.spi.ScanException; +import ch.qos.logback.core.status.Status; /** * @author Ceki @@ -202,4 +211,97 @@ public void settingTimeZoneOptionHasAnEffect() { FileNamePattern fnp = new FileNamePattern("%d{hh, " + tz.getID() + "}", context); assertEquals(tz, fnp.getPrimaryDateTokenConverter().getTimeZone()); } + + @Test + public void unparsablePatternIsReportedAsError() { + FileNamePattern fnp = new FileNamePattern("app-%d{yyyy-MM-dd.log", context); + + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(1, statuses.size()); + Status status = statuses.get(0); + assertEquals(Status.ERROR, status.getLevel()); + assertEquals("Failed to parse pattern \"app-%d{yyyy-MM-dd.log\".", status.getMessage()); + assertTrue(status.getThrowable() instanceof ScanException); + // nothing to convert + assertNull(fnp.headTokenConverter); + assertEquals("", fnp.convertInt(1)); + assertEquals("app-%d{yyyy-MM-dd.log", fnp.getPattern()); + } + + @Test + public void equalityAndHashCodeDependOnThePatternOnly() { + FileNamePattern fnp = new FileNamePattern("app-%i.log", context); + FileNamePattern samePattern = new FileNamePattern("app-%i.log", new ContextBase()); + FileNamePattern otherPattern = new FileNamePattern("other-%i.log", context); + + assertTrue(fnp.equals(fnp)); + assertTrue(fnp.equals(samePattern)); + assertEquals(fnp.hashCode(), samePattern.hashCode()); + assertEquals(31 + "app-%i.log".hashCode(), fnp.hashCode()); + assertFalse(fnp.equals(otherPattern)); + assertFalse(fnp.equals(null)); + assertFalse(fnp.equals("app-%i.log")); + assertFalse(fnp.equals(new FileNamePattern("app-%i.log", context) {})); + } + + @Test + public void equalityAndHashCodeHandleMissingPattern() { + FileNamePattern withoutPattern = new FileNamePattern("app-%i.log", context); + withoutPattern.pattern = null; + FileNamePattern alsoWithoutPattern = new FileNamePattern("other-%i.log", context); + alsoWithoutPattern.pattern = null; + FileNamePattern withPattern = new FileNamePattern("app-%i.log", context); + + assertEquals(31, withoutPattern.hashCode()); + assertTrue(withoutPattern.equals(alsoWithoutPattern)); + assertFalse(withoutPattern.equals(withPattern)); + assertFalse(withPattern.equals(withoutPattern)); + } + + @Test + public void settingNullPatternKeepsCurrentPattern() { + FileNamePattern fnp = new FileNamePattern("app-%i.log", context); + + fnp.setPattern(null); + + assertEquals("app-%i.log", fnp.getPattern()); + assertEquals("app-%i.log", fnp.toString()); + } + + @Test + public void asRegexForFixedDateKeepsAuxiliaryDateVariable() { + Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal.clear(); + cal.set(2019, Calendar.NOVEMBER, 4); + FileNamePattern fnp = new FileNamePattern("logs/%d{yyyy/MM, aux}/app-%d{yyyy-MM-dd, GMT}.log", context); + + String regex = fnp.toRegexForFixedDate(cal.getTime()); + + assertEquals("logs/" + FileFinder.regexEscapePath("\\d{4}/\\d{2}") + "/app-2019-11-04.log", regex); + Pattern p = Pattern.compile(FileFinder.unescapePath(regex)); + // the auxiliary token matches any directory, the primary one only the fixed date + assertTrue(p.matcher("logs/2018/03/app-2019-11-04.log").matches()); + assertFalse(p.matcher("logs/2019/11/app-2019-11-05.log").matches()); + } + + @Test + public void asRegexIgnoresConvertersOfOtherTypes() { + FileNamePattern fnp = new FileNamePattern("unused", context); + // only literal, integer and date converters contribute to a regex + Converter head = new LiteralConverter("a"); + Converter other = new Converter() { + @Override + public String convert(Object event) { + return "X"; + } + }; + head.setNext(other); + other.setNext(new LiteralConverter("b")); + fnp.headTokenConverter = head; + + assertEquals("ab", fnp.toRegex()); + assertEquals("ab", fnp.toRegexForFixedDate(new Date(0))); + // whereas the converter does take part in plain conversions + assertEquals("aXb", fnp.convert(null)); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileSorterTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileSorterTest.java index 64fe31d32..83882b6b9 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileSorterTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileSorterTest.java @@ -119,6 +119,26 @@ public void sortsDescendingByDateAndIntegerWithMultipleDatesInPattern() { assertThat(sort("/var/logs/my-app/%d{yyyy-MM,aux}/%d{yyyy-MM-dd}/%i.log", FILENAMES), contains(EXPECTED_RESULT)); } + @Test + public void sortsDescendingByNameWhenDateCannotBeParsed() { + final String PATTERN = "/var/logs/my-app/%d{yyyy-MM-dd}.log"; + // one name without a date: comparing it with a dated name falls back to + // the raw names, in whichever order the two are compared + final String[] EXPECTED_RESULT = new String[] { + "/var/logs/my-app/undated.log", + "/var/logs/my-app/2019-01-01.log", + }; + + assertThat(sort(PATTERN, new String[] { + "/var/logs/my-app/2019-01-01.log", + "/var/logs/my-app/undated.log", + }), contains(EXPECTED_RESULT)); + assertThat(sort(PATTERN, new String[] { + "/var/logs/my-app/undated.log", + "/var/logs/my-app/2019-01-01.log", + }), contains(EXPECTED_RESULT)); + } + private List sort(String pattern, String[] filenames) { FileNamePattern fileNamePattern = new FileNamePattern(pattern, new LoggerContext()); FileSorter sorter = new FileSorter(new DateParser(fileNamePattern), new IntParser(fileNamePattern)); diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileStoreUtilTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileStoreUtilTest.java index cef72f57a..9b723e44d 100755 --- a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileStoreUtilTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileStoreUtilTest.java @@ -21,16 +21,70 @@ import ch.qos.logback.core.util.EnvUtil; import ch.qos.logback.core.util.FileUtil; import org.junit.Ignore; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import java.io.File; import java.io.IOException; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class FileStoreUtilTest { + @Rule + public TemporaryFolder tmpDir = new TemporaryFolder(); + + @Test + public void isInstantiable() { + // only static members, but the class is public and has a public constructor + assertNotNull(new FileStoreUtil()); + } + + @Test + public void missingFirstFileIsRejected() throws IOException { + File missing = new File(tmpDir.getRoot(), "missing"); + File existing = tmpDir.newFile("existing"); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> FileStoreUtil.areOnSameFileStore(missing, existing)); + + assertEquals("File [" + missing + "] does not exist.", e.getMessage()); + } + + @Test + public void missingSecondFileIsRejected() throws IOException { + File existing = tmpDir.newFile("existing"); + File missing = new File(tmpDir.getRoot(), "missing"); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> FileStoreUtil.areOnSameFileStore(existing, missing)); + + assertEquals("File [" + missing + "] does not exist.", e.getMessage()); + } + + @Test + public void failedFileStoreLookupIsReportedAsRolloverFailure() throws IOException { + File existing = tmpDir.newFile("existing"); + // passes the existence check, but is gone by the time its store is looked up + File vanished = new File(tmpDir.getRoot(), "vanished") { + @Override + public boolean exists() { + return true; + } + }; + + RolloverFailure e = assertThrows(RolloverFailure.class, + () -> FileStoreUtil.areOnSameFileStore(existing, vanished)); + + assertEquals("Failed to check file store equality for [" + existing + "] and [" + vanished + "]", e.getMessage()); + assertNotNull(e.getCause()); + } + int diff = RandomUtil.getPositiveInt(); String pathPrefix = CoreTestConstants.OUTPUT_DIR_PREFIX+"fs"+diff+"/"; diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/IntParserTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/IntParserTest.java new file mode 100644 index 000000000..488cfbd40 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/IntParserTest.java @@ -0,0 +1,50 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.rolling.helper; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; + +public class IntParserTest { + + private final Context context = new ContextBase(); + + @Test + public void parsesIntegerOfMatchingFilename() { + IntParser parser = new IntParser(new FileNamePattern("/logs/%d{yyyy-MM-dd}/app-%i.log", context)); + + assertEquals(Integer.valueOf(12), parser.parseFilename("/logs/2019-11-04/app-12.log")); + } + + @Test + public void filenameNotMatchingThePatternParsesAsMinusOne() { + IntParser parser = new IntParser(new FileNamePattern("/logs/%d{yyyy-MM-dd}/app-%i.log", context)); + + assertEquals(Integer.valueOf(-1), parser.parseFilename("/logs/other.txt")); + } + + @Test + public void patternWithoutIntegerTokenParsesAsMinusOne() { + IntParser parser = new IntParser(new FileNamePattern("/logs/app-%d{yyyy-MM-dd}.log", context)); + + // the filename matches, but there is no integer token to capture + assertEquals(Integer.valueOf(-1), parser.parseFilename("/logs/app-2019-11-04.log")); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/IntegerTokenConverterTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/IntegerTokenConverterTest.java new file mode 100644 index 000000000..bd8824bbb --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/IntegerTokenConverterTest.java @@ -0,0 +1,47 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.rolling.helper; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +public class IntegerTokenConverterTest { + + private final IntegerTokenConverter converter = new IntegerTokenConverter(); + + @Test + public void convertsInteger() { + assertEquals("42", converter.convert((Object) Integer.valueOf(42))); + } + + @Test + public void convertingNullIsForbidden() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> converter.convert((Object) null)); + + assertEquals("Null argument forbidden", e.getMessage()); + } + + @Test + public void convertingNonIntegerIsRejected() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> converter.convert((Object) "42")); + + assertEquals("Cannot convert 42 of typejava.lang.String", e.getMessage()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/RenameUtilTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/RenameUtilTest.java new file mode 100644 index 000000000..d81ebbee3 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/RenameUtilTest.java @@ -0,0 +1,283 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.rolling.helper; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.rolling.RolloverFailure; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.util.EnvUtil; +import ch.qos.logback.core.util.FileUtil; + +/** + * Tests {@link RenameUtil} from within its package (see also + * ch.qos.logback.core.rolling.RenameUtilTest). + */ +public class RenameUtilTest { + + private static final String CONTENT = "some log data\n"; + + @Rule + public TemporaryFolder tmpDir = new TemporaryFolder(); + + private final Context context = new ContextBase(); + private RenameUtil renameUtil; + private File source; + + @Before + public void setUp() throws IOException { + renameUtil = new RenameUtil(); + renameUtil.setContext(context); + source = tmpDir.newFile("app.log"); + FileOutputStream fos = new FileOutputStream(source); + try { + fos.write(CONTENT.getBytes("UTF-8")); + } finally { + fos.close(); + } + } + + @Test + public void renamingFileToItselfIsSkipped() throws IOException { + renameUtil.rename(source.getPath(), source.getPath()); + + assertTrue(hasStatus(Status.WARN, "Source and target files are the same [" + source.getPath() + "]. Skipping.")); + assertFalse(hasStatusStartingWith(Status.INFO, "Renaming file")); + assertEquals(CONTENT, read(source)); + } + + @Test + public void renamingMissingFileFails() { + File missing = new File(tmpDir.getRoot(), "missing.log"); + File target = new File(tmpDir.getRoot(), "renamed.log"); + + RolloverFailure e = assertThrows(RolloverFailure.class, + () -> renameUtil.rename(missing.getPath(), target.getPath())); + + assertEquals("File [" + missing.getPath() + "] does not exist.", e.getMessage()); + assertFalse(target.exists()); + } + + @Test + public void renamingIntoDirectoryThatCannotBeCreatedFails() throws IOException { + // the target's grandparent is a regular file, so its parent can't be created + File blocker = tmpDir.newFile("blocker"); + File target = new File(new File(blocker, "sub"), "renamed.log"); + + RolloverFailure e = assertThrows(RolloverFailure.class, + () -> renameUtil.rename(source.getPath(), target.getPath())); + + assertEquals("Failed to create parent directories for [" + target.getAbsolutePath() + "]", e.getMessage()); + assertEquals(CONTENT, read(source)); + } + + @Test + public void failedRenameOnSameVolumeKeepsSourceAndWarns() throws IOException { + // a file cannot replace an existing directory + File target = tmpDir.newFolder("occupied"); + + renameUtil.rename(source.getPath(), target.getPath()); + + assertTrue(hasStatus(Status.WARN, "Failed to rename file [" + source + "] as [" + target + "].")); + assertTrue(hasStatus(Status.WARN, "Please consider leaving the [file] option of RollingFileAppender empty.")); + assertTrue(hasStatus(Status.WARN, "See also " + RenameUtil.RENAMING_ERROR_URL)); + assertFalse(hasStatusStartingWith(Status.WARN, "Detected different file systems")); + assertEquals(CONTENT, read(source)); + assertTrue(target.isDirectory()); + } + + @Test + public void failedRenameAcrossVolumesFallsBackToCopying() throws IOException { + File target = tmpDir.newFolder("occupied"); + RenameUtil spiedRenameUtil = spy(renameUtil); + doReturn(Boolean.TRUE).when(spiedRenameUtil).areOnDifferentVolumes(any(File.class), any(File.class)); + doNothing().when(spiedRenameUtil).renameByCopying(anyString(), anyString()); + + spiedRenameUtil.rename(source.getPath(), target.getPath()); + + verify(spiedRenameUtil).renameByCopying(source.getPath(), target.getPath()); + assertTrue(hasStatus(Status.WARN, "Failed to rename file [" + source + "] as [" + target + "].")); + assertTrue(hasStatus(Status.WARN, "Detected different file systems for source [" + source.getPath() + + "] and target [" + target.getPath() + "]. Attempting rename by copying.")); + assertFalse(hasStatusStartingWith(Status.WARN, "Please consider")); + } + + @Test + public void filesOnSameFileStoreAreNotOnDifferentVolumes() { + File target = new File(tmpDir.getRoot(), "renamed.log"); + + assertEquals(Boolean.FALSE, renameUtil.areOnDifferentVolumes(source, target)); + } + + @Test + public void filesOnDifferentFileStoresAreOnDifferentVolumes() { + File target = new File(tmpDir.getRoot(), "renamed.log"); + + try (MockedStatic fileStoreUtil = mockStatic(FileStoreUtil.class)) { + fileStoreUtil.when(() -> FileStoreUtil.areOnSameFileStore(any(File.class), any(File.class))).thenReturn(false); + + assertEquals(Boolean.TRUE, renameUtil.areOnDifferentVolumes(source, target)); + + fileStoreUtil.verify(() -> FileStoreUtil.areOnSameFileStore(source, target.getAbsoluteFile().getParentFile())); + } + } + + @Test + public void volumesAreNotComparedBeforeJava7() { + // a target that would otherwise make the check inconclusive + File targetWithoutParent = new File(File.separator); + + try (MockedStatic envUtil = mockStatic(EnvUtil.class)) { + envUtil.when(EnvUtil::isJDK7OrHigher).thenReturn(false); + + assertEquals(Boolean.FALSE, renameUtil.areOnDifferentVolumes(source, targetWithoutParent)); + } + assertEquals(0, context.getStatusManager().getCount()); + } + + @Test + public void volumesAreUnknownForTargetWithoutParent() { + File targetWithoutParent = new File(File.separator); + + assertNull(renameUtil.areOnDifferentVolumes(source, targetWithoutParent)); + + assertTrue(hasStatus(Status.WARN, "Parent of target file [" + targetWithoutParent + "] is null")); + } + + @Test + public void volumesAreUnknownForTargetInMissingDirectory() { + File target = new File(new File(tmpDir.getRoot(), "missing"), "renamed.log"); + + assertNull(renameUtil.areOnDifferentVolumes(source, target)); + + assertTrue(hasStatus(Status.WARN, "Parent of target file [" + target + "] does not exist")); + } + + @Test + public void volumesAreUnknownWhenFileStoreCheckFails() { + // passes the existence check, but is gone by the time its store is looked up + File vanished = new File(tmpDir.getRoot(), "vanished.log") { + @Override + public boolean exists() { + return true; + } + }; + File target = new File(tmpDir.getRoot(), "renamed.log"); + + assertNull(renameUtil.areOnDifferentVolumes(vanished, target)); + + Status warning = findStatus(Status.WARN, "Error while checking file store equality"); + assertTrue(warning.getThrowable() instanceof RolloverFailure); + } + + @Test + public void renameByCopyingCopiesContentAndDeletesSource() throws IOException { + File target = new File(tmpDir.getRoot(), "copy.log"); + + renameUtil.renameByCopying(source.getPath(), target.getPath()); + + assertEquals(CONTENT, read(target)); + assertFalse(source.exists()); + assertFalse(hasStatusStartingWith(Status.WARN, "Could not delete")); + } + + @Test + public void renameByCopyingWarnsWhenSourceCannotBeDeleted() throws IOException { + // a non-empty directory cannot be deleted + File undeletable = tmpDir.newFolder("undeletable"); + assertTrue(new File(undeletable, "child").createNewFile()); + File target = new File(tmpDir.getRoot(), "copy.log"); + + try (MockedConstruction fileUtils = mockConstruction(FileUtil.class)) { + renameUtil.renameByCopying(undeletable.getPath(), target.getPath()); + + assertEquals(1, fileUtils.constructed().size()); + verify(fileUtils.constructed().get(0)).copy(undeletable.getPath(), target.getPath()); + } + assertTrue(hasStatus(Status.WARN, "Could not delete " + undeletable.getPath())); + assertTrue(undeletable.isDirectory()); + } + + private Status findStatus(int level, String message) { + for (Status s : context.getStatusManager().getCopyOfStatusList()) { + if (s.getLevel() == level && message.equals(s.getMessage())) { + return s; + } + } + throw new AssertionError("no status [" + message + "] at level " + level); + } + + private boolean hasStatus(int level, String message) { + for (Status s : context.getStatusManager().getCopyOfStatusList()) { + if (s.getLevel() == level && message.equals(s.getMessage())) { + return true; + } + } + return false; + } + + private boolean hasStatusStartingWith(int level, String prefix) { + for (Status s : context.getStatusManager().getCopyOfStatusList()) { + if (s.getLevel() == level && s.getMessage().startsWith(prefix)) { + return true; + } + } + return false; + } + + private static String read(File file) throws IOException { + InputStream in = new FileInputStream(file); + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[1024]; + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + } + return out.toString("UTF-8"); + } finally { + in.close(); + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/RollingCalendarTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/RollingCalendarTest.java index 4ac32b39e..bce17360e 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/RollingCalendarTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/RollingCalendarTest.java @@ -17,6 +17,7 @@ import static ch.qos.logback.core.rolling.helper.RollingCalendar.GMT_TIMEZONE; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertFalse; @@ -24,10 +25,14 @@ import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.Date; +import java.util.List; import java.util.Locale; import org.junit.Test; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.spi.ContextAwareBase; +import ch.qos.logback.core.status.Status; import ch.qos.logback.core.util.EnvUtil; public class RollingCalendarTest { @@ -89,6 +94,77 @@ public void roundsDateWithMissingTimeUnits() throws ParseException { assertEquals(0, cal.get(Calendar.MILLISECOND)); } + @Test + public void weeklyPeriodsStartOnTheFirstDayOfTheWeek() throws ParseException { + // a Wednesday; weeks start on Sunday in the US locale + final Date REF_DATE = parseDate("yyyy-MM-dd HH:mm:ss.SSS", "2000-12-27 09:30:49.876"); + SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS EEE", Locale.US); + format.setTimeZone(GMT_TIMEZONE); + + assertEquals("2000-12-17 00:00:00.000 Sun", format.format(getEndOfNextNthPeriod("yyyy-ww", REF_DATE, -1).getTime())); + assertEquals("2000-12-24 00:00:00.000 Sun", format.format(getEndOfNextNthPeriod("yyyy-ww", REF_DATE, 0).getTime())); + assertEquals("2000-12-31 00:00:00.000 Sun", format.format(getEndOfNextNthPeriod("yyyy-ww", REF_DATE, 1).getTime())); + assertEquals("2001-01-07 00:00:00.000 Sun", format.format(getEndOfNextNthPeriod("yyyy-ww", REF_DATE, 2).getTime())); + } + + @Test + public void periodicityIsErroneousWithoutDatePattern() { + RollingCalendar rc = new RollingCalendar(null, GMT_TIMEZONE, Locale.US); + + assertEquals(PeriodicityType.ERRONEOUS, rc.getPeriodicityType()); + } + + @Test + public void periodicityIsErroneousWithoutAnyTimeUnit() { + RollingCalendar rc = new RollingCalendar("yyyy", GMT_TIMEZONE, Locale.US); + + assertEquals(PeriodicityType.ERRONEOUS, rc.getPeriodicityType()); + } + + @Test + public void amPmMarkerMeansHalfDayPeriodicity() { + RollingCalendar rc = new RollingCalendar("yyyy-MM-dd a", GMT_TIMEZONE, Locale.US); + + assertEquals(PeriodicityType.HALF_DAY, rc.getPeriodicityType()); + } + + @Test + public void endOfPeriodIsUndefinedForErroneousPeriodicity() { + RollingCalendar rc = new RollingCalendar("yyyy", GMT_TIMEZONE, Locale.US); + + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> rc.getEndOfNextNthPeriod(new Date(0), 1)); + + assertEquals("Unknown periodicity type.", e.getMessage()); + } + + @Test + public void endOfPeriodIsUndefinedForHalfDayPeriodicity() { + RollingCalendar rc = new RollingCalendar("yyyy-MM-dd a", GMT_TIMEZONE, Locale.US); + + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> rc.getNextTriggeringDate(new Date(0))); + + assertEquals("Unknown periodicity type.", e.getMessage()); + } + + @Test + public void printsPeriodicity() { + assertEquals("Roll-over at midday and midnight.", printedPeriodicity("yyyy-MM-dd a")); + assertEquals("Rollover at the start of week.", printedPeriodicity("yyyy-ww")); + assertEquals("Unknown periodicity.", printedPeriodicity("yyyy")); + } + + private String printedPeriodicity(String datePattern) { + ContextAwareBase cab = new ContextAwareBase(); + cab.setContext(new ContextBase()); + new RollingCalendar(datePattern, GMT_TIMEZONE, Locale.US).printPeriodicity(cab); + List statuses = cab.getStatusManager().getCopyOfStatusList(); + assertEquals(1, statuses.size()); + assertEquals(Status.INFO, statuses.get(0).getLevel()); + return statuses.get(0).getMessage(); + } + private Calendar getEndOfNextNthPeriod(String dateFormat, Date date, int n) { RollingCalendar rc = new RollingCalendar(dateFormat, GMT_TIMEZONE, Locale.US); Date nextDate = rc.getEndOfNextNthPeriod(date, n); diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/TimeBasedArchiveRemoverTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/TimeBasedArchiveRemoverTest.java index 96c6c09d4..87ca083f8 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/TimeBasedArchiveRemoverTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/TimeBasedArchiveRemoverTest.java @@ -32,7 +32,10 @@ import java.util.TimeZone; import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.core.Context; +import ch.qos.logback.core.status.Status; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -141,6 +144,85 @@ public void keepsRecentFilesAndOlderFilesWithinTotalSizeCap() { } } + @Test + public void warnsWhenAnExpiredFileCannotBeDeleted() { + doReturn(false).when(fileProvider).deleteFile(any(File.class)); + + remover.clean(EXPIRY); + + for (File f : expiredFiles) { + verify(fileProvider).deleteFile(f); + assertTrue("missing warning for " + f, hasStatus(remover.getContext(), Status.WARN, "cannot delete " + f)); + } + for (File f : recentFiles) { + assertFalse(hasStatus(remover.getContext(), Status.WARN, "cannot delete " + f)); + } + } + + @Test + public void totalSizeCapCountsOnlyTheFilesActuallyDeleted() { + final long FILE_SIZE = 1024L; + doReturn(FILE_SIZE).when(fileProvider).length(any(File.class)); + doReturn(true).when(fileProvider).deleteFile(any(File.class)); + // expiredFiles[0] is app_20191103.log, expiredFiles[1] is app_20191102.log + File undeletable = expiredFiles[0]; + doReturn(false).when(fileProvider).deleteFile(undeletable); + remover.setMaxHistory(MAX_HISTORY); + remover.setTotalSizeCap(2 * FILE_SIZE); + + remover.clean(EXPIRY); + + // newest first: 1105 and 1104 fit in the cap; 1103 and 1102 exceed it + verify(fileProvider).deleteFile(undeletable); + verify(fileProvider).deleteFile(expiredFiles[1]); + for (File f : recentFiles) { + verify(fileProvider, never()).deleteFile(f); + } + Context context = remover.getContext(); + assertTrue(hasStatus(context, Status.WARN, "cannot delete " + undeletable)); + // the file that could not be deleted neither counts as removed nor + // pushes the remaining files further over the cap + assertTrue(hasStatus(context, Status.INFO, "Removed 1 KB of files")); + } + + @Test + public void nonPositiveTotalSizeCapDisablesSizeCapping() { + doReturn(1024L).when(fileProvider).length(any(File.class)); + remover.setMaxHistory(MAX_HISTORY); + remover.setTotalSizeCap(-1); + + remover.clean(EXPIRY); + + verify(fileProvider, never()).length(any(File.class)); + for (File f : recentFiles) { + verify(fileProvider, never()).deleteFile(f); + } + for (File f : Arrays.asList(expiredFiles).subList(0, 2)) { + verify(fileProvider, never()).deleteFile(f); + } + for (Status s : remover.getContext().getStatusManager().getCopyOfStatusList()) { + assertFalse(s.getMessage(), s.getMessage().startsWith("Removed ")); + } + } + + @Test + public void keepsEmptyDirectoryOfARetainedPeriod() throws IOException { + File currentPeriodDir = tmpDir.newFolder("cur_2019", "11"); + File expiredPeriodDir = tmpDir.newFolder("cur_2018", "05"); + TimeBasedArchiveRemover gmtRemover = newGmtArchiveRemover( + "cur_%d{yyyy/MM, " + TIMEZONE_NAME + ", aux}/app_%d{" + DATE_FORMAT + ", " + TIMEZONE_NAME + "}.log"); + + gmtRemover.clean(EXPIRY); + + // the current period's directory is empty (e.g. its active file is being + // re-created) but must not be removed; an old empty one must be + verify(fileProvider, never()).deleteFile(currentPeriodDir); + verify(fileProvider, never()).deleteFile(currentPeriodDir.getParentFile()); + assertTrue(currentPeriodDir.isDirectory()); + verify(fileProvider).deleteFile(expiredPeriodDir); + assertFalse(expiredPeriodDir.exists()); + } + @Test public void removesEmptyParentOfARemovedEmptyDirInTheSamePass() throws IOException { File emptyMonth = tmpDir.newFolder("nested_2017", "05"); @@ -174,6 +256,11 @@ public void removesEmptyParentOfARemovedEmptyDirInTheSamePass() throws IOExcepti assertTrue(unrelatedFile.exists()); } + @Test + public void toStringNamesTheRemover() { + assertEquals("c.q.l.core.rolling.helper.TimeBasedArchiveRemover", remover.toString()); + } + private TimeBasedArchiveRemover newGmtArchiveRemover(String relativePattern) { LoggerContext context = new LoggerContext(); RollingCalendar rollingCalendar = new RollingCalendar(DATE_FORMAT, TimeZone.getTimeZone(TIMEZONE_NAME), Locale.US); @@ -183,6 +270,15 @@ private TimeBasedArchiveRemover newGmtArchiveRemover(String relativePattern) { return archiveRemover; } + private static boolean hasStatus(Context context, int level, String message) { + for (Status s : context.getStatusManager().getCopyOfStatusList()) { + if (s.getLevel() == level && message.equals(s.getMessage())) { + return true; + } + } + return false; + } + private void setupSizeCapTest() { final long FILE_SIZE = 1024L; // XXX: Need to use doReturn().when() here to avoid NPE diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/TokenConverterTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/TokenConverterTest.java new file mode 100644 index 000000000..5cd492b0a --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/TokenConverterTest.java @@ -0,0 +1,47 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.rolling.helper; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +import org.junit.Test; + +public class TokenConverterTest { + + @Test + public void typeIsSetAtConstructionAndCanBeChanged() { + TokenConverter converter = new TokenConverter(TokenConverter.INTEGER); + assertEquals(TokenConverter.INTEGER, converter.getType()); + + converter.setType(TokenConverter.IDENTITY); + + assertEquals(TokenConverter.IDENTITY, converter.getType()); + } + + @Test + public void convertersCanBeChained() { + TokenConverter head = new TokenConverter(TokenConverter.IDENTITY); + TokenConverter next = new TokenConverter(TokenConverter.DATE); + assertNull(head.getNext()); + + head.setNext(next); + + assertSame(next, head.getNext()); + assertNull(next.getNext()); + } +} From 698ff1648cfa4294a5955939bf88fdc6d76d8d68 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:23:34 +0000 Subject: [PATCH 14/79] test: tighten core.rolling.helper tests against surviving mutants - CompressorTest: a compression that fails before its temp file exists must not warn about deleting that temp file. - TimeBasedArchiveRemoverTest: a parent that still holds another entry after its empty child is removed must not be deleted in the same pass. - DateParserTest: pin a non-GMT default time zone (restored afterwards) so that the default-zone and explicit-zone paths are told apart on UTC hosts; cover a primary date token without a time zone. - FileFilterUtilTest: extractCounter needs the stem regex to match the whole name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../core/rolling/helper/CompressorTest.java | 4 +++ .../core/rolling/helper/DateParserTest.java | 30 +++++++++++++++++-- .../rolling/helper/FileFilterUtilTest.java | 9 ++++++ .../helper/TimeBasedArchiveRemoverTest.java | 20 +++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/CompressorTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/CompressorTest.java index 16bf65082..9a06ac05f 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/CompressorTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/CompressorTest.java @@ -133,12 +133,16 @@ public void failureToCreateTargetDirectoryIsReportedAndSourceIsKept() throws IOE // the target's grandparent is a regular file, so its parent can't be created File blocker = tmpDir.newFile("blocker"); File target = new File(new File(blocker, "sub"), "app.log.gz"); + File tmpFile = new File(target.getPath() + Compressor.TMP_SUFFIX); compressor(CompressionMode.GZ).compress(source.getPath(), target.getPath(), null); assertTrue(hasStatus(Status.ERROR, "Failed to create parent directories for [" + target.getAbsolutePath() + "]")); assertTrue(hasStatusStartingWith(Status.ERROR, "Error occurred while compressing")); assertFalse(target.exists()); + // the temp file was never created, so there is nothing to clean up + assertFalse(tmpFile.exists()); + assertFalse(hasStatusStartingWith(Status.WARN, "Could not delete temporary file")); assertEquals(CONTENT, read(source)); } diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/DateParserTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/DateParserTest.java index d3a8a57b1..77e25bba5 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/DateParserTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/DateParserTest.java @@ -24,6 +24,8 @@ import java.util.Locale; import java.util.TimeZone; +import org.junit.After; +import org.junit.Before; import org.junit.Test; import ch.qos.logback.core.Context; @@ -31,16 +33,40 @@ public class DateParserTest { + // a fixed default time zone that differs from GMT, so that parsing in the + // default zone and parsing in GMT give different dates + private static final TimeZone DEFAULT_TIME_ZONE = TimeZone.getTimeZone("GMT+05:30"); + private final Context context = new ContextBase(); + private TimeZone originalDefaultTimeZone; + + @Before + public void setUp() { + originalDefaultTimeZone = TimeZone.getDefault(); + TimeZone.setDefault(DEFAULT_TIME_ZONE); + } + + @After + public void tearDown() { + TimeZone.setDefault(originalDefaultTimeZone); + } @Test - public void parsesPrimaryDateOfMatchingFilename() throws ParseException { + public void parsesPrimaryDateOfMatchingFilenameInItsTimeZone() throws ParseException { DateParser parser = new DateParser(new FileNamePattern("/logs/app-%d{yyyy-MM-dd, GMT}.log", context)); assertEquals(parse("yyyy-MM-dd", TimeZone.getTimeZone("GMT"), "2019-11-04"), parser.parseFilename("/logs/app-2019-11-04.log")); } + @Test + public void parsesPrimaryDateWithoutTimeZoneInDefaultTimeZone() throws ParseException { + DateParser parser = new DateParser(new FileNamePattern("/logs/app-%d{yyyy-MM-dd}.log", context)); + + assertEquals(parse("yyyy-MM-dd", DEFAULT_TIME_ZONE, "2019-11-04"), + parser.parseFilename("/logs/app-2019-11-04.log")); + } + @Test public void filenameNotMatchingThePatternHasNoDate() { DateParser parser = new DateParser(new FileNamePattern("/logs/app-%d{yyyy-MM-dd, GMT}.log", context)); @@ -60,7 +86,7 @@ public void patternWithoutPrimaryDateYieldsNoDate() { public void patternWithoutPrimaryDateParsesWithDefaultDatePatternAndTimeZone() throws ParseException { DateParser parser = new DateParser(new FileNamePattern("/logs/app-%d{yyyy/MM, aux}-%i.log", context)); - assertEquals(parse(DateTokenConverter.DEFAULT_DATE_PATTERN, TimeZone.getDefault(), "2019-11-04"), + assertEquals(parse(DateTokenConverter.DEFAULT_DATE_PATTERN, DEFAULT_TIME_ZONE, "2019-11-04"), parser.parseDate("2019-11-04")); } diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileFilterUtilTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileFilterUtilTest.java index 0a9c77692..e6efdba2e 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileFilterUtilTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/FileFilterUtilTest.java @@ -83,4 +83,13 @@ public void extractingCounterOfNonMatchingFileFails() { assertEquals("The regex [" + STEM_REGEX + "] should match [other.log]", e.getMessage()); } + + @Test + public void extractingCounterRequiresTheWholeNameToMatch() { + // the stem regex occurs in the name, but does not match all of it + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> FileFilterUtil.extractCounter(new File("logs", "app.12.log.gz"), STEM_REGEX)); + + assertEquals("The regex [" + STEM_REGEX + "] should match [app.12.log.gz]", e.getMessage()); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/TimeBasedArchiveRemoverTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/TimeBasedArchiveRemoverTest.java index 87ca083f8..f4b47b1b5 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/TimeBasedArchiveRemoverTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/helper/TimeBasedArchiveRemoverTest.java @@ -256,6 +256,26 @@ public void removesEmptyParentOfARemovedEmptyDirInTheSamePass() throws IOExcepti assertTrue(unrelatedFile.exists()); } + @Test + public void keepsParentOfARemovedEmptyDirWhenItHoldsOtherEntries() throws IOException { + File emptyMonth = tmpDir.newFolder("shared_2017", "05"); + File year = emptyMonth.getParentFile(); + File unrelatedFile = new File(year, "notes.txt"); + assertTrue(unrelatedFile.createNewFile()); + TimeBasedArchiveRemover gmtRemover = newGmtArchiveRemover( + "shared_%d{yyyy/MM, " + TIMEZONE_NAME + ", aux}/app_%d{" + DATE_FORMAT + ", " + TIMEZONE_NAME + "}.log"); + + gmtRemover.clean(EXPIRY); + + verify(fileProvider).deleteFile(emptyMonth); + assertFalse(emptyMonth.exists()); + // the year directory still holds another entry once its empty month is + // gone, so its removal is not even attempted + verify(fileProvider, never()).deleteFile(year); + assertTrue(unrelatedFile.exists()); + assertFalse(hasStatus(gmtRemover.getContext(), Status.WARN, "cannot delete " + year)); + } + @Test public void toStringNamesTheRemover() { assertEquals("c.q.l.core.rolling.helper.TimeBasedArchiveRemover", remover.toString()); From d13cbd9d1623fdaf27d963785a4b5fd969114203 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:42:04 +0000 Subject: [PATCH 15/79] test: cover SMTPAppender and SMTPAppenderBase SMTPAppenderBaseTest now pins the mail session built at start (host, port, localhost, authentication, the SSL+STARTTLS conflict, a missing session), append's entry checks, evaluation (sync and async sending, evaluator errors capped at MAX_ERROR_COUNT, discriminator, end of life, tracker status messages) and the message sendBuffer builds: body with layout headers/footers, subject (default, truncated, null), sender, recipients (skipped, unparsable, none), content type and charset, the updateMimeMsg hook and transport failures. Transport.send is mocked statically, so no SMTP server is needed. The new SMTPAppenderTest pins the evaluator given to the constructor, caller data extraction, fillBuffer and eventMarksEndOfLife. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../logback/classic/net/SMTPAppenderTest.java | 211 +++++ .../core/net/SMTPAppenderBaseTest.java | 773 +++++++++++++++++- 2 files changed, 983 insertions(+), 1 deletion(-) create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/net/SMTPAppenderTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/SMTPAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/SMTPAppenderTest.java new file mode 100644 index 000000000..4b0a9c906 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/SMTPAppenderTest.java @@ -0,0 +1,211 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.net; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.Test; +import org.slf4j.Marker; +import org.slf4j.MarkerFactory; + +import ch.qos.logback.classic.ClassicConstants; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.PatternLayout; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.core.boolex.EvaluationException; +import ch.qos.logback.core.boolex.EventEvaluatorBase; +import ch.qos.logback.core.helpers.CyclicBuffer; + +public class SMTPAppenderTest { + + private final LoggerContext context = new LoggerContext(); + + private LoggingEvent event(Level level, String message) { + return new LoggingEvent(getClass().getName(), context.getLogger("test"), level, message, null, null); + } + + private PatternLayout layout(String pattern) { + PatternLayout layout = new PatternLayout(); + layout.setContext(context); + layout.setPattern(pattern); + layout.start(); + return layout; + } + + private A configure(A appender) { + appender.setContext(context); + appender.setName("smtp"); + appender.setLayout(layout("%msg;")); + appender.addTo("to@example.com"); + appender.setAsynchronousSending(false); + return appender; + } + + private static LoggingEvent withMarkers(LoggingEvent event, Marker... markers) { + event.setMarkers(Arrays.asList(markers)); + return event; + } + + // ------------------------------------------------------------- evaluator + + @Test + public void defaultEvaluatorSendsOnErrorEvents() { + BufferRecordingSMTPAppender appender = configure(new BufferRecordingSMTPAppender()); + appender.start(); + + appender.doAppend(event(Level.WARN, "warn")); + assertTrue(appender.sentBuffers.isEmpty()); + + appender.doAppend(event(Level.ERROR, "error")); + assertEquals(Collections.singletonList(Arrays.asList("warn", "error")), appender.sentBuffers); + } + + @Test + public void evaluatorGivenToTheConstructorIsKeptAtStart() { + EventEvaluatorBase onSendMessage = new EventEvaluatorBase() { + @Override + public boolean evaluate(ILoggingEvent event) throws EvaluationException { + return "send".equals(event.getMessage()); + } + }; + BufferRecordingSMTPAppender appender = configure(new BufferRecordingSMTPAppender(onSendMessage)); + appender.start(); + + // the default evaluator would have sent on this ERROR event + appender.doAppend(event(Level.ERROR, "error")); + assertTrue(appender.sentBuffers.isEmpty()); + + appender.doAppend(event(Level.DEBUG, "send")); + assertEquals(Collections.singletonList(Arrays.asList("error", "send")), appender.sentBuffers); + } + + // ------------------------------------------------------------- subAppend + + @Test + public void callerDataIsNotIncludedByDefault() { + SMTPAppender appender = new SMTPAppender(); + assertFalse(appender.isIncludeCallerData()); + + LoggingEvent event = event(Level.ERROR, "x"); + CyclicBuffer cb = new CyclicBuffer(4); + appender.subAppend(cb, event); + + assertFalse(event.hasCallerData()); + assertEquals(1, cb.length()); + } + + @Test + public void callerDataIsExtractedBeforeBufferingWhenIncluded() { + SMTPAppender appender = new SMTPAppender(); + appender.setIncludeCallerData(true); + assertTrue(appender.isIncludeCallerData()); + + LoggingEvent event = event(Level.ERROR, "x"); + CyclicBuffer cb = new CyclicBuffer(4); + appender.subAppend(cb, event); + + assertTrue(event.hasCallerData()); + assertNotNull(event.getCallerData()); + assertEquals(Collections.singletonList(event), cb.asList()); + } + + // ------------------------------------------------------------- fillBuffer + + @Test + public void fillBufferLaysOutEveryBufferedEventInOrderAndDrainsTheBuffer() { + SMTPAppender appender = new SMTPAppender(); + appender.setLayout(layout("<%msg>")); + CyclicBuffer cb = new CyclicBuffer(4); + cb.add(event(Level.INFO, "one")); + cb.add(event(Level.ERROR, "two")); + StringBuffer sbuf = new StringBuffer("start:"); + + appender.fillBuffer(cb, sbuf); + + assertEquals("start:", sbuf.toString()); + assertEquals(0, cb.length()); + } + + // ----------------------------------------------------- eventMarksEndOfLife + + @Test + public void eventWithoutMarkersDoesNotMarkEndOfLife() { + assertFalse(new SMTPAppender().eventMarksEndOfLife(event(Level.INFO, "x"))); + } + + @Test + public void eventWithEmptyMarkerListDoesNotMarkEndOfLife() { + LoggingEvent event = event(Level.INFO, "x"); + event.setMarkers(Collections.emptyList()); + assertFalse(new SMTPAppender().eventMarksEndOfLife(event)); + } + + @Test + public void eventWithUnrelatedMarkersDoesNotMarkEndOfLife() { + LoggingEvent event = withMarkers(event(Level.INFO, "x"), + MarkerFactory.getDetachedMarker("A"), MarkerFactory.getDetachedMarker("B")); + assertFalse(new SMTPAppender().eventMarksEndOfLife(event)); + } + + @Test + public void finalizeSessionMarkerMarksEndOfLife() { + LoggingEvent event = withMarkers(event(Level.INFO, "x"), ClassicConstants.FINALIZE_SESSION_MARKER); + assertTrue(new SMTPAppender().eventMarksEndOfLife(event)); + } + + @Test + public void markerReferencingFinalizeSessionAfterAnUnrelatedOneMarksEndOfLife() { + Marker parent = MarkerFactory.getDetachedMarker("PARENT"); + parent.add(ClassicConstants.FINALIZE_SESSION_MARKER); + LoggingEvent event = withMarkers(event(Level.INFO, "x"), MarkerFactory.getDetachedMarker("UNRELATED"), parent); + assertTrue(new SMTPAppender().eventMarksEndOfLife(event)); + } + + /** + * Records the messages of the buffered events it would send instead of + * sending an e-mail. + */ + static class BufferRecordingSMTPAppender extends SMTPAppender { + + final List> sentBuffers = new ArrayList>(); + + BufferRecordingSMTPAppender() { + } + + BufferRecordingSMTPAppender(EventEvaluatorBase evaluator) { + super(evaluator); + } + + @Override + protected void sendBuffer(CyclicBuffer cb, ILoggingEvent lastEventObject) { + List messages = new ArrayList(); + for (ILoggingEvent event : cb.asList()) { + messages.add(event.getMessage()); + } + sentBuffers.add(messages); + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/SMTPAppenderBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/SMTPAppenderBaseTest.java index 3b3d38f22..3b0e26f78 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/SMTPAppenderBaseTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/SMTPAppenderBaseTest.java @@ -15,35 +15,187 @@ */ package ch.qos.logback.core.net; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Properties; +import java.util.concurrent.ScheduledExecutorService; + +import javax.mail.Address; +import javax.mail.Authenticator; +import javax.mail.Message; +import javax.mail.MessagingException; +import javax.mail.PasswordAuthentication; +import javax.mail.Session; +import javax.mail.Transport; +import javax.mail.internet.AddressException; +import javax.mail.internet.InternetAddress; +import javax.mail.internet.MimeBodyPart; +import javax.mail.internet.MimeMessage; +import javax.mail.internet.MimeMultipart; import org.junit.After; +import org.junit.Before; import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.slf4j.Marker; +import ch.qos.logback.classic.ClassicConstants; +import ch.qos.logback.classic.Level; import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.PatternLayout; import ch.qos.logback.classic.net.SMTPAppender; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.Layout; +import ch.qos.logback.core.LayoutBase; +import ch.qos.logback.core.boolex.EvaluationException; +import ch.qos.logback.core.boolex.EventEvaluator; +import ch.qos.logback.core.helpers.CyclicBuffer; +import ch.qos.logback.core.sift.DefaultDiscriminator; +import ch.qos.logback.core.sift.Discriminator; +import ch.qos.logback.core.spi.CyclicBufferTracker; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; public class SMTPAppenderBaseTest { private static final String CHECK_SERVER_IDENTITY = "mail.smtp.ssl.checkserveridentity"; + private static final String DEFAULT_SENDER = "default-sender@example.com"; + private static final String NO_PARSE_ADDRESS = " sentMessages = new ArrayList(); private final SMTPAppender appender = new SMTPAppender(); + /** + * Stands in for the SMTP server: every message handed to + * {@link Transport#send(Message)} on the test thread is recorded in + * {@link #sentMessages} instead of being delivered. + */ + private MockedStatic transport; + + @Before + public void setUp() { + transport = mockStatic(Transport.class); + transport.when(() -> Transport.send(any(Message.class))).thenAnswer(invocation -> { + sentMessages.add(invocation.getArgument(0)); + return null; + }); + } + @After public void tearDown() { + transport.close(); System.clearProperty(CHECK_SERVER_IDENTITY); } private void start() { - appender.setContext(new LoggerContext()); + appender.setContext(context); appender.setSMTPHost("localhost"); appender.addTo("nospam@qos.ch"); appender.start(); assertTrue(appender.isStarted()); } + /** + * Sets up {@code a} so that it can append: context, name, a "[%msg]" + * layout, and synchronous sending. Recipients are left to the caller. + */ + private A configure(A a) { + a.setContext(context); + a.setName("smtp"); + a.setLayout(patternLayout("[%msg]")); + a.setAsynchronousSending(false); + return a; + } + + /** + * Starts {@code a} and gives its mail session a default sender, used when no + * "from" is set (this also avoids a lookup of the local host name). + */ + private A startForSending(A a) { + a.start(); + assertTrue(a.isStarted()); + a.session.getProperties().put("mail.from", DEFAULT_SENDER); + return a; + } + + /** {@link #appender} seen as the class under test, to reach its package-private members. */ + private SMTPAppenderBase base() { + return appender; + } + + private PatternLayout patternLayout(String pattern) { + PatternLayout layout = new PatternLayout(); + layout.setContext(context); + layout.setPattern(pattern); + layout.start(); + return layout; + } + + private LoggingEvent event(Level level, String message) { + return new LoggingEvent(getClass().getName(), context.getLogger("test"), level, message, null, null); + } + + private MimeMessage onlySentMessage() { + assertEquals(1, sentMessages.size()); + return (MimeMessage) sentMessages.get(0); + } + + private static MimeBodyPart onlyBodyPart(MimeMessage message) throws Exception { + MimeMultipart multipart = (MimeMultipart) message.getContent(); + assertEquals(1, multipart.getCount()); + return (MimeBodyPart) multipart.getBodyPart(0); + } + + private static String body(MimeMessage message) throws Exception { + return (String) onlyBodyPart(message).getContent(); + } + + private static String contentType(MimeBodyPart part) throws MessagingException { + return part.getDataHandler().getContentType(); + } + + private static List addresses(Address[] addresses) { + List result = new ArrayList(); + if (addresses != null) { + for (Address address : addresses) { + result.add(((InternetAddress) address).getAddress()); + } + } + return result; + } + + private int bufferedEventCount() { + return appender.getCyclicBufferTracker().getOrCreate(DefaultDiscriminator.DEFAULT, 0).length(); + } + + // ---------------------------------------------------------------- start() + @Test public void checksServerIdentityWithSSL() { appender.setSSL(true); @@ -71,4 +223,623 @@ public void plainSMTPLeavesServerIdentityCheckUnset() { start(); assertNull(appender.session.getProperty(CHECK_SERVER_IDENTITY)); } + + @Test + public void sslTogetherWithStartTlsIsReportedAndEnablesNeither() { + appender.setSSL(true); + appender.setSTARTTLS(true); + assertTrue(appender.isSSL()); + assertTrue(appender.isSTARTTLS()); + + start(); + + checker.assertContainsMatch(Status.ERROR, "Both SSL and StartTLS cannot be enabled simultaneously"); + assertNull(appender.session.getProperty("mail.smtp.ssl.enable")); + assertNull(appender.session.getProperty("mail.smtp.starttls.enable")); + assertNull(appender.session.getProperty(CHECK_SERVER_IDENTITY)); + } + + @Test + public void hostPortAndLocalhostArePassedToTheMailSession() { + appender.setContext(context); + appender.setSmtpHost("mail.example.com"); + appender.setSmtpPort(2525); + appender.setLocalhost("client.example.com"); + appender.start(); + + assertTrue(appender.isStarted()); + assertEquals("mail.example.com", appender.getSmtpHost()); + assertEquals("mail.example.com", appender.getSMTPHost()); + assertEquals("mail.example.com", appender.session.getProperty("mail.smtp.host")); + assertEquals(2525, appender.getSmtpPort()); + assertEquals(2525, appender.getSMTPPort()); + assertEquals("2525", appender.session.getProperty("mail.smtp.port")); + assertEquals("client.example.com", appender.getLocalhost()); + assertEquals("client.example.com", appender.session.getProperty("mail.smtp.localhost")); + } + + @Test + public void setSMTPPortIsAnAliasOfSetSmtpPort() { + appender.setSMTPPort(465); + assertEquals(465, appender.getSmtpPort()); + } + + @Test + public void unsetHostAndLocalhostAreLeftOutOfTheMailSession() { + appender.setContext(context); + appender.start(); + + assertTrue(appender.isStarted()); + assertNull(appender.getSmtpHost()); + assertNull(appender.session.getProperty("mail.smtp.host")); + assertEquals("25", appender.session.getProperty("mail.smtp.port")); + assertNull(appender.getLocalhost()); + assertNull(appender.session.getProperty("mail.smtp.localhost")); + } + + @Test + public void usernameEnablesAuthenticationWithTheConfiguredCredentials() { + appender.setUsername("alice"); + appender.setPassword("secret"); + assertEquals("alice", appender.getUsername()); + assertEquals("secret", appender.getPassword()); + + start(); + + assertEquals("true", appender.session.getProperty("mail.smtp.auth")); + PasswordAuthentication credentials = + appender.session.requestPasswordAuthentication(null, 25, "smtp", null, null); + assertNotNull(credentials); + assertEquals("alice", credentials.getUserName()); + assertEquals("secret", credentials.getPassword()); + } + + @Test + public void withoutUsernameTheSessionHasNoAuthentication() { + start(); + + assertNull(appender.session.getProperty("mail.smtp.auth")); + assertNull(appender.session.requestPasswordAuthentication(null, 25, "smtp", null, null)); + } + + @Test + public void startKeepsACyclicBufferTrackerSetBeforehand() { + CyclicBufferTracker tracker = new CyclicBufferTracker(); + appender.setCyclicBufferTracker(tracker); + start(); + assertSame(tracker, appender.getCyclicBufferTracker()); + } + + @Test + public void startCreatesACyclicBufferTrackerWhenNoneIsSet() { + assertNull(appender.getCyclicBufferTracker()); + start(); + assertNotNull(appender.getCyclicBufferTracker()); + } + + @Test + public void startFailsWhenNoMailSessionCanBeObtained() { + appender.setContext(context); + try (MockedStatic sessions = mockStatic(Session.class)) { + sessions.when(() -> Session.getInstance(any(Properties.class), nullable(Authenticator.class))) + .thenReturn(null); + appender.start(); + } + + assertFalse(appender.isStarted()); + assertNull(appender.subjectLayout); + checker.assertContainsMatch(Status.ERROR, "Failed to obtain javax.mail.Session. Cannot start."); + } + + @Test + public void stopMarksTheAppenderAsNotStarted() { + start(); + appender.stop(); + assertFalse(appender.isStarted()); + } + + // ------------------------------------------------------ append() entry checks + + @Test + public void appendingToAStoppedAppenderIsReportedAndBuffersNothing() throws Exception { + startForSending(configure(appender)); + appender.addTo("to@example.com"); + appender.stop(); + + appender.append(event(Level.ERROR, "dropped")); + + checker.assertContainsMatch(Status.ERROR, "Attempting to append to a non-started appender: smtp"); + assertFalse(appender.checkEntryConditions()); + assertEquals(0, appender.getCyclicBufferTracker().getComponentCount()); + assertTrue(sentMessages.isEmpty()); + } + + @Test + public void checkEntryConditionsPassesForAFullyConfiguredStartedAppender() throws Exception { + startForSending(configure(appender)); + assertTrue(appender.checkEntryConditions()); + checker.assertIsErrorFree(); + } + + // ------------------------------------------------------ append() evaluation + + @Test + public void nonTriggeringEventIsBufferedWithoutSending() throws Exception { + startForSending(configure(appender)); + appender.addTo("to@example.com"); + + appender.append(event(Level.DEBUG, "kept")); + + assertTrue(sentMessages.isEmpty()); + assertEquals(1, bufferedEventCount()); + } + + @Test + public void triggeringEventSendsTheBufferSynchronouslyAndClearsIt() throws Exception { + configure(appender); + appender.addTo("to@example.com"); + startForSending(appender); + assertFalse(appender.isAsynchronousSending()); + + appender.append(event(Level.DEBUG, "one")); + appender.append(event(Level.ERROR, "two")); + + MimeMessage message = onlySentMessage(); + assertEquals("[one][two]", body(message)); + assertEquals(Collections.singletonList("to@example.com"), + addresses(message.getRecipients(Message.RecipientType.TO))); + assertEquals(0, bufferedEventCount()); + + // the next e-mail only carries what was logged after the previous one + appender.append(event(Level.ERROR, "three")); + assertEquals(2, sentMessages.size()); + assertEquals("[three]", body((MimeMessage) sentMessages.get(1))); + } + + @Test + public void triggeringEventIsSentAsynchronouslyThroughTheContextExecutor() throws Exception { + final ScheduledExecutorService executor = mock(ScheduledExecutorService.class); + LoggerContext asyncContext = new LoggerContext() { + @Override + public synchronized ScheduledExecutorService getScheduledExecutorService() { + return executor; + } + }; + SMTPAppender asyncAppender = new SMTPAppender(); + asyncAppender.setContext(asyncContext); + asyncAppender.setName("smtp"); + asyncAppender.setLayout(patternLayout("[%msg]")); + asyncAppender.addTo("to@example.com"); + assertTrue(asyncAppender.isAsynchronousSending()); + startForSending(asyncAppender); + + asyncAppender.append(new LoggingEvent("fqcn", asyncContext.getLogger("test"), Level.DEBUG, "one", null, null)); + asyncAppender.append(new LoggingEvent("fqcn", asyncContext.getLogger("test"), Level.ERROR, "two", null, null)); + + ArgumentCaptor sender = ArgumentCaptor.forClass(Runnable.class); + verify(executor).execute(sender.capture()); + assertTrue("nothing is sent before the executor runs the task", sentMessages.isEmpty()); + // the buffer is handed over (cloned) to the task and cleared right away + assertEquals(0, asyncAppender.getCyclicBufferTracker().getOrCreate(DefaultDiscriminator.DEFAULT, 0).length()); + + sender.getValue().run(); + + assertEquals("[one][two]", body(onlySentMessage())); + } + + @Test + public void evaluatorExceptionsAreReportedOnlyUpToTheMaximumErrorCount() throws Exception { + @SuppressWarnings("unchecked") + EventEvaluator evaluator = mock(EventEvaluator.class); + when(evaluator.evaluate(any(ILoggingEvent.class))).thenThrow(new EvaluationException("boom")); + appender.setEvaluator(evaluator); + configure(appender); + appender.addTo("to@example.com"); + startForSending(appender); + + for (int i = 0; i < CoreConstants.MAX_ERROR_COUNT + 2; i++) { + appender.append(event(Level.ERROR, "e" + i)); + } + + assertEquals(CoreConstants.MAX_ERROR_COUNT - 1, + checker.matchCount("SMTPAppender's EventEvaluator threw an Exception-")); + assertTrue(sentMessages.isEmpty()); + assertEquals(CoreConstants.MAX_ERROR_COUNT + 2, bufferedEventCount()); + } + + @Test + public void eventsAreBufferedUnderTheDiscriminatingValue() throws Exception { + @SuppressWarnings("unchecked") + Discriminator discriminator = mock(Discriminator.class); + when(discriminator.getDiscriminatingValue(any(ILoggingEvent.class))).thenReturn("session-42"); + appender.setDiscriminator(discriminator); + assertSame(discriminator, appender.getDiscriminator()); + configure(appender); + appender.addTo("to@example.com"); + startForSending(appender); + + appender.append(event(Level.DEBUG, "kept")); + + CyclicBufferTracker tracker = appender.getCyclicBufferTracker(); + assertEquals(1, tracker.getComponentCount()); + assertEquals(1, tracker.find("session-42").length()); + } + + @Test + public void finalizeSessionMarkerEndsTheLifeOfTheEventsBuffer() throws Exception { + CyclicBufferTracker tracker = spy(new CyclicBufferTracker()); + appender.setCyclicBufferTracker(tracker); + configure(appender); + appender.addTo("to@example.com"); + startForSending(appender); + + appender.append(event(Level.DEBUG, "ordinary")); + verify(tracker, never()).endOfLife(anyString()); + + LoggingEvent last = event(Level.DEBUG, "last"); + last.setMarkers(Collections.singletonList(ClassicConstants.FINALIZE_SESSION_MARKER)); + appender.append(last); + verify(tracker).endOfLife(DefaultDiscriminator.DEFAULT); + } + + // --------------------------------------------- append() tracker status messages + + @Test + public void trackerStatusIsReportedAndItsDelayQuadrupled() throws Exception { + configure(appender); + appender.addTo("to@example.com"); + startForSending(appender); + long initialDelay = base().delayBetweenStatusMessages; + + appender.append(event(Level.DEBUG, "first")); + + checker.assertContainsMatch(Status.INFO, "SMTPAppender \\[smtp\\] is tracking \\[1\\] buffers"); + assertEquals(4 * initialDelay, base().delayBetweenStatusMessages); + assertTrue(base().lastTrackerStatusPrint > 0); + } + + @Test + public void trackerStatusIsNotRepeatedBeforeTheDelayElapses() throws Exception { + configure(appender); + appender.addTo("to@example.com"); + startForSending(appender); + long delay = base().delayBetweenStatusMessages; + // the next status message is due at the end of time + base().lastTrackerStatusPrint = Long.MAX_VALUE - delay; + + appender.append(event(Level.DEBUG, "quiet")); + + assertEquals(0, checker.matchCount("SMTPAppender \\[smtp\\] is tracking")); + assertEquals(delay, base().delayBetweenStatusMessages); + assertEquals(Long.MAX_VALUE - delay, base().lastTrackerStatusPrint); + } + + @Test + public void trackerStatusDelayStopsGrowingAtTheMaximum() throws Exception { + configure(appender); + appender.addTo("to@example.com"); + startForSending(appender); + base().delayBetweenStatusMessages = SMTPAppenderBase.MAX_DELAY_BETWEEN_STATUS_MESSAGES; + + appender.append(event(Level.DEBUG, "first")); + + assertEquals(1, checker.matchCount("SMTPAppender \\[smtp\\] is tracking")); + assertEquals(SMTPAppenderBase.MAX_DELAY_BETWEEN_STATUS_MESSAGES, base().delayBetweenStatusMessages); + } + + // ------------------------------------------------------------ sendBuffer() + + @Test + public void sentMessageWrapsTheEventsInTheLayoutHeadersAndFooters() throws Exception { + PatternLayout layout = new PatternLayout(); + layout.setContext(context); + layout.setPattern("[%msg]"); + layout.setFileHeader("FH|"); + layout.setPresentationHeader("PH|"); + layout.setPresentationFooter("|PF"); + layout.setFileFooter("|FF"); + layout.start(); + configure(appender); + appender.setLayout(layout); + appender.setFrom("sender@example.com"); + appender.setSubject("report %msg"); + appender.addTo("to@example.com"); + startForSending(appender); + + appender.append(event(Level.INFO, "one")); + appender.append(event(Level.ERROR, "two")); + + MimeMessage message = onlySentMessage(); + assertEquals("FH|PH|[one][two]|PF|FF", body(message)); + assertEquals("report two", message.getSubject()); + assertEquals(Collections.singletonList("sender@example.com"), addresses(message.getFrom())); + assertNotNull(message.getSentDate()); + checker.assertContainsMatch(Status.INFO, + "About to send out SMTP message \"report two\" to \\[to@example.com\\]"); + } + + @Test + public void textualContentIsSentWithTheLayoutSubtypeAndTheCharsetEncoding() throws Exception { + PatternLayout htmlLayout = new PatternLayout() { + @Override + public String getContentType() { + return "text/html"; + } + }; + htmlLayout.setContext(context); + htmlLayout.setPattern("

%msg

"); + htmlLayout.start(); + configure(appender); + appender.setLayout(htmlLayout); + appender.setCharsetEncoding("ISO-8859-1"); + assertEquals("ISO-8859-1", appender.getCharsetEncoding()); + appender.addTo("to@example.com"); + startForSending(appender); + + appender.append(event(Level.ERROR, "café")); + + MimeBodyPart part = onlyBodyPart(onlySentMessage()); + assertEquals("text/html; charset=ISO-8859-1", contentType(part)); + assertEquals("

café

", part.getContent()); + } + + @Test + public void plainTextLayoutContentDefaultsToUtf8() throws Exception { + configure(appender); + assertEquals("UTF-8", appender.getCharsetEncoding()); + appender.addTo("to@example.com"); + startForSending(appender); + + appender.append(event(Level.ERROR, "plain")); + + assertEquals("text/plain; charset=UTF-8", contentType(onlyBodyPart(onlySentMessage()))); + } + + @Test + public void nonTextualContentIsSentWithTheLayoutContentType() throws Exception { + LayoutBase jsonLayout = new LayoutBase() { + @Override + public String doLayout(ILoggingEvent event) { + return "{\"msg\":\"" + event.getMessage() + "\"}"; + } + + @Override + public String getContentType() { + return "application/json"; + } + }; + jsonLayout.setContext(context); + jsonLayout.start(); + configure(appender); + appender.setLayout(jsonLayout); + assertSame(jsonLayout, appender.getLayout()); + appender.addTo("to@example.com"); + startForSending(appender); + + appender.append(event(Level.ERROR, "x")); + + MimeBodyPart part = onlyBodyPart(onlySentMessage()); + assertEquals("application/json", contentType(part)); + assertEquals("{\"msg\":\"x\"}", part.getContent()); + } + + @Test + public void subjectIsTruncatedAtTheFirstNewLine() throws Exception { + configure(appender); + appender.setSubject("%msg"); + appender.addTo("to@example.com"); + startForSending(appender); + + appender.append(event(Level.ERROR, "first line\nsecond line")); + + assertEquals("first line", onlySentMessage().getSubject()); + } + + @Test + public void subjectIsUndefinedWithoutASubjectLayout() throws Exception { + configure(appender); + appender.addTo("to@example.com"); + startForSending(appender); + appender.subjectLayout = null; + + appender.append(event(Level.ERROR, "x")); + + assertEquals("Undefined subject", onlySentMessage().getSubject()); + } + + @Test + public void nullSubjectFromTheSubjectLayoutIsSentWithoutSubject() throws Exception { + @SuppressWarnings("unchecked") + Layout nullSubject = mock(Layout.class); + configure(appender); + appender.addTo("to@example.com"); + startForSending(appender); + appender.subjectLayout = nullSubject; + + appender.append(event(Level.ERROR, "x")); + + assertNull(onlySentMessage().getSubject()); + checker.assertIsErrorFree(); + } + + @Test + public void withoutFromTheSessionDefaultSenderIsUsed() throws Exception { + configure(appender); + assertNull(appender.getFrom()); + appender.addTo("to@example.com"); + startForSending(appender); + + appender.append(event(Level.ERROR, "x")); + + assertEquals(Collections.singletonList(DEFAULT_SENDER), addresses(onlySentMessage().getFrom())); + } + + @Test + public void unparsableFromIsReportedAndTheMessageIsSentWithoutSender() throws Exception { + configure(appender); + appender.setFrom(NO_PARSE_ADDRESS); + assertEquals(NO_PARSE_ADDRESS, appender.getFrom()); + appender.addTo("to@example.com"); + startForSending(appender); + + appender.append(event(Level.ERROR, "x")); + + checker.assertContainsMatch(Status.ERROR, "Could not parse address \\[" + NO_PARSE_ADDRESS + "\\]."); + assertNull(onlySentMessage().getFrom()); + } + + @Test + public void getAddressParsesAValidAddress() { + appender.setContext(context); + InternetAddress address = base().getAddress("Alice "); + assertEquals("alice@example.com", address.getAddress()); + assertEquals("Alice", address.getPersonal()); + checker.assertIsErrorFree(); + } + + @Test + public void getAddressReturnsNullForAnUnparsableAddress() { + appender.setContext(context); + assertNull(base().getAddress(NO_PARSE_ADDRESS)); + checker.asssertContainsException(AddressException.class); + } + + @Test + public void everyParsedRecipientReceivesTheMessage() throws Exception { + configure(appender); + appender.addTo("a@example.com"); + appender.addTo("B , c@example.com"); + startForSending(appender); + + appender.append(event(Level.ERROR, "x")); + + assertEquals(Arrays.asList("a@example.com", "b@example.com", "c@example.com"), + addresses(onlySentMessage().getRecipients(Message.RecipientType.TO))); + } + + @Test + public void recipientsThatResolveToNullOrEmptyAreSkipped() throws Exception { + SMTPAppender nullRecipientAppender = new SMTPAppender() { + @Override + protected PatternLayout makeNewToPatternLayout(String toPattern) { + if (!"null-recipient".equals(toPattern)) { + return super.makeNewToPatternLayout(toPattern); + } + PatternLayout nullLayout = new PatternLayout() { + @Override + public String doLayout(ILoggingEvent event) { + return null; + } + }; + nullLayout.setPattern(toPattern); + return nullLayout; + } + }; + configure(nullRecipientAppender); + nullRecipientAppender.addTo("null-recipient"); + nullRecipientAppender.addTo("%X{absentKey}"); + nullRecipientAppender.addTo("to@example.com"); + startForSending(nullRecipientAppender); + + nullRecipientAppender.append(event(Level.ERROR, "x")); + + assertEquals(Collections.singletonList("to@example.com"), + addresses(onlySentMessage().getRecipients(Message.RecipientType.TO))); + } + + @Test + public void noResolvedRecipientAbortsTheTransmission() throws Exception { + configure(appender); + appender.addTo("%X{absentKey}"); + startForSending(appender); + + appender.append(event(Level.ERROR, "x")); + + assertTrue(sentMessages.isEmpty()); + checker.assertContainsMatch(Status.INFO, "Empty destination address. Aborting email transmission"); + assertEquals(0, checker.matchCount("About to send out SMTP message")); + } + + @Test + public void unparsableRecipientStopsAddressParsing() throws Exception { + configure(appender); + appender.addTo(NO_PARSE_ADDRESS); + appender.addTo("to@example.com"); + startForSending(appender); + + appender.append(event(Level.ERROR, "x")); + + checker.assertContainsMatch(Status.ERROR, + "Could not parse email address for \\[.*" + NO_PARSE_ADDRESS + ".*\\] for event \\[.*x\\]"); + // recipients after the unparsable one are not considered + assertTrue(sentMessages.isEmpty()); + checker.assertContainsMatch(Status.INFO, "Empty destination address. Aborting email transmission"); + } + + @Test + public void updateMimeMsgCanAlterTheMessageBeforeItIsSent() throws Exception { + final List hookEvents = new ArrayList(); + SMTPAppender headerAppender = new SMTPAppender() { + @Override + protected void updateMimeMsg(MimeMessage mimeMsg, CyclicBuffer cb, ILoggingEvent lastEventObject) { + assertNotNull(cb); + hookEvents.add(lastEventObject.getMessage()); + try { + mimeMsg.setHeader("X-Logback-Test", "added"); + } catch (MessagingException e) { + throw new IllegalStateException(e); + } + } + }; + configure(headerAppender); + headerAppender.addTo("to@example.com"); + startForSending(headerAppender); + + headerAppender.append(event(Level.INFO, "one")); + headerAppender.append(event(Level.ERROR, "two")); + + assertEquals(Collections.singletonList("two"), hookEvents); + assertArrayEquals(new String[] {"added"}, onlySentMessage().getHeader("X-Logback-Test")); + } + + @Test + public void transportFailureIsReportedAsAnError() throws Exception { + configure(appender); + appender.addTo("to@example.com"); + startForSending(appender); + transport.when(() -> Transport.send(any(Message.class))).thenThrow(new MessagingException("server unreachable")); + + appender.append(event(Level.ERROR, "x")); + + assertTrue(sentMessages.isEmpty()); + checker.assertContainsMatch(Status.ERROR, "Error occurred while sending e-mail notification."); + checker.asssertContainsException(MessagingException.class); + } + + // ------------------------------------------------------------ recipients + + @Test + public void addToTrimsThePatternAndExposesIt() { + appender.setContext(context); + appender.addTo(" to@example.com "); + + assertEquals(Collections.singletonList("to@example.com%nopex"), appender.getToAsListOfString()); + assertEquals(1, appender.getToList().size()); + assertEquals("to@example.com%nopex", appender.getToList().get(0).getPattern()); + assertTrue(appender.getToList().get(0).isStarted()); + } + + @Test + public void addToRejectsANullRecipient() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> appender.addTo(null)); + assertEquals("Null or empty property", e.getMessage()); + assertTrue(appender.getToList().isEmpty()); + } + + @Test + public void addToRejectsAnEmptyRecipient() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> appender.addTo("")); + assertEquals("Null or empty property", e.getMessage()); + assertTrue(appender.getToList().isEmpty()); + } } From 36d34786d92c8d827b365648c3f4158f7eeae35e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:48:09 +0000 Subject: [PATCH 16/79] test: pin SMTP appender behavior that mutations slipped past MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add assertions and tests for behavior that survived mutation of SMTPAppenderBase and SMTPAppender: - the SSL and STARTTLS session properties - the missing evaluator and missing layout checks (checkEntryConditions returns false, and nothing is buffered or sent) - stale buffers being removed on append - the subject being encoded with charsetEncoding - a subject that starts with a newline (truncation boundary) - deferred-processing preparation of buffered events - the default subject pattern, with no exception appended to the subject - setup of the default OnErrorEvaluator Use a é escape instead of a non-ASCII literal, so the test does not depend on the source encoding. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../logback/classic/net/SMTPAppenderTest.java | 56 +++++++++ .../core/net/SMTPAppenderBaseTest.java | 107 +++++++++++++++++- 2 files changed, 161 insertions(+), 2 deletions(-) diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/SMTPAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/SMTPAppenderTest.java index 4b0a9c906..fdf2f347c 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/net/SMTPAppenderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/SMTPAppenderTest.java @@ -18,6 +18,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import java.util.ArrayList; @@ -33,9 +34,12 @@ import ch.qos.logback.classic.Level; import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.PatternLayout; +import ch.qos.logback.classic.boolex.OnErrorEvaluator; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.core.Layout; import ch.qos.logback.core.boolex.EvaluationException; +import ch.qos.logback.core.boolex.EventEvaluator; import ch.qos.logback.core.boolex.EventEvaluatorBase; import ch.qos.logback.core.helpers.CyclicBuffer; @@ -76,6 +80,12 @@ public void defaultEvaluatorSendsOnErrorEvents() { BufferRecordingSMTPAppender appender = configure(new BufferRecordingSMTPAppender()); appender.start(); + EventEvaluator evaluator = appender.evaluator(); + assertTrue(evaluator instanceof OnErrorEvaluator); + assertEquals("onError", evaluator.getName()); + assertSame(context, evaluator.getContext()); + assertTrue(evaluator.isStarted()); + appender.doAppend(event(Level.WARN, "warn")); assertTrue(appender.sentBuffers.isEmpty()); @@ -132,6 +142,48 @@ public void callerDataIsExtractedBeforeBufferingWhenIncluded() { assertEquals(Collections.singletonList(event), cb.asList()); } + @Test + public void bufferedEventKeepsItsMessageAsFormattedWhenItWasAppended() { + SMTPAppender appender = new SMTPAppender(); + StringBuilder state = new StringBuilder("before"); + LoggingEvent event = new LoggingEvent(getClass().getName(), context.getLogger("test"), Level.ERROR, + "state={}", null, new Object[] {state}); + CyclicBuffer cb = new CyclicBuffer(4); + + appender.subAppend(cb, event); + // the argument changes before the buffered event is laid out in an e-mail + state.replace(0, state.length(), "after"); + + assertEquals("state=before", cb.get().getFormattedMessage()); + } + + // ------------------------------------------------------------- subject + + @Test + public void defaultSubjectIsTheLoggerAndMessageWithoutTheException() { + SMTPAppender appender = new SMTPAppender(); + appender.setContext(context); + Layout subject = appender.makeSubjectLayout(null); + + LoggingEvent event = new LoggingEvent(getClass().getName(), context.getLogger("test"), Level.ERROR, + "failed", new IllegalStateException("boom"), null); + + assertTrue(subject.isStarted()); + assertEquals("test - failed", subject.doLayout(event)); + } + + @Test + public void configuredSubjectPatternIsUsedWithoutTheException() { + SMTPAppender appender = new SMTPAppender(); + appender.setContext(context); + Layout subject = appender.makeSubjectLayout("[%level] %msg"); + + LoggingEvent event = new LoggingEvent(getClass().getName(), context.getLogger("test"), Level.ERROR, + "failed", new IllegalStateException("boom"), null); + + assertEquals("[ERROR] failed", subject.doLayout(event)); + } + // ------------------------------------------------------------- fillBuffer @Test @@ -199,6 +251,10 @@ static class BufferRecordingSMTPAppender extends SMTPAppender { super(evaluator); } + EventEvaluator evaluator() { + return eventEvaluator; + } + @Override protected void sendBuffer(CyclicBuffer cb, ILoggingEvent lastEventObject) { List messages = new ArrayList(); diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/SMTPAppenderBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/SMTPAppenderBaseTest.java index 3b0e26f78..80c71cece 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/SMTPAppenderBaseTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/SMTPAppenderBaseTest.java @@ -224,6 +224,32 @@ public void plainSMTPLeavesServerIdentityCheckUnset() { assertNull(appender.session.getProperty(CHECK_SERVER_IDENTITY)); } + @Test + public void sslIsEnabledOnTheMailSession() { + appender.setSSL(true); + start(); + assertEquals("true", appender.session.getProperty("mail.smtp.ssl.enable")); + assertNull(appender.session.getProperty("mail.smtp.starttls.enable")); + checker.assertIsErrorFree(); + } + + @Test + public void startTlsIsEnabledOnTheMailSession() { + appender.setSTARTTLS(true); + start(); + assertEquals("true", appender.session.getProperty("mail.smtp.starttls.enable")); + assertEquals("true", appender.session.getProperty("mail.transport.protocol")); + assertNull(appender.session.getProperty("mail.smtp.ssl.enable")); + checker.assertIsErrorFree(); + } + + @Test + public void plainSMTPEnablesNeitherSslNorStartTls() { + start(); + assertNull(appender.session.getProperty("mail.smtp.ssl.enable")); + assertNull(appender.session.getProperty("mail.smtp.starttls.enable")); + } + @Test public void sslTogetherWithStartTlsIsReportedAndEnablesNeither() { appender.setSSL(true); @@ -354,6 +380,37 @@ public void appendingToAStoppedAppenderIsReportedAndBuffersNothing() throws Exce assertTrue(sentMessages.isEmpty()); } + @Test + public void appendingWithoutAnEvaluatorIsReportedAndBuffersNothing() throws Exception { + startForSending(configure(appender)); + appender.addTo("to@example.com"); + appender.setEvaluator(null); + + assertFalse(appender.checkEntryConditions()); + checker.assertContainsMatch(Status.ERROR, "No EventEvaluator is set for appender \\[smtp\\]\\."); + + appender.append(event(Level.ERROR, "dropped")); + + assertEquals(0, appender.getCyclicBufferTracker().getComponentCount()); + assertTrue(sentMessages.isEmpty()); + } + + @Test + public void appendingWithoutALayoutIsReportedAndBuffersNothing() throws Exception { + startForSending(configure(appender)); + appender.addTo("to@example.com"); + appender.setLayout(null); + + assertFalse(appender.checkEntryConditions()); + checker.assertContainsMatch(Status.ERROR, "No layout set for appender named \\[smtp\\]\\. " + + "For more information, please visit http://logback.qos.ch/codes.html#smtp_no_layout"); + + appender.append(event(Level.ERROR, "dropped")); + + assertEquals(0, appender.getCyclicBufferTracker().getComponentCount()); + assertTrue(sentMessages.isEmpty()); + } + @Test public void checkEntryConditionsPassesForAFullyConfiguredStartedAppender() throws Exception { startForSending(configure(appender)); @@ -482,6 +539,24 @@ public void finalizeSessionMarkerEndsTheLifeOfTheEventsBuffer() throws Exception verify(tracker).endOfLife(DefaultDiscriminator.DEFAULT); } + @Test + public void appendRemovesBuffersThatWentStale() throws Exception { + CyclicBufferTracker tracker = new CyclicBufferTracker(); + // last used at the epoch: long past the tracker's timeout + tracker.getOrCreate("stale", 0); + appender.setCyclicBufferTracker(tracker); + configure(appender); + appender.addTo("to@example.com"); + startForSending(appender); + + appender.append(event(Level.DEBUG, "fresh")); + + assertNull(tracker.find("stale")); + assertEquals(1, tracker.getComponentCount()); + assertEquals(1, tracker.find(DefaultDiscriminator.DEFAULT).length()); + checker.assertContainsMatch(Status.INFO, "SMTPAppender \\[smtp\\] is tracking \\[1\\] buffers"); + } + // --------------------------------------------- append() tracker status messages @Test @@ -543,6 +618,7 @@ public void sentMessageWrapsTheEventsInTheLayoutHeadersAndFooters() throws Excep appender.setLayout(layout); appender.setFrom("sender@example.com"); appender.setSubject("report %msg"); + assertEquals("report %msg", appender.getSubject()); appender.addTo("to@example.com"); startForSending(appender); @@ -576,11 +652,11 @@ public String getContentType() { appender.addTo("to@example.com"); startForSending(appender); - appender.append(event(Level.ERROR, "café")); + appender.append(event(Level.ERROR, "caf\u00e9")); MimeBodyPart part = onlyBodyPart(onlySentMessage()); assertEquals("text/html; charset=ISO-8859-1", contentType(part)); - assertEquals("

café

", part.getContent()); + assertEquals("

caf\u00e9

", part.getContent()); } @Test @@ -623,6 +699,21 @@ public String getContentType() { assertEquals("{\"msg\":\"x\"}", part.getContent()); } + @Test + public void subjectIsEncodedWithTheCharsetEncoding() throws Exception { + configure(appender); + appender.setCharsetEncoding("ISO-8859-1"); + appender.setSubject("%msg"); + appender.addTo("to@example.com"); + startForSending(appender); + + appender.append(event(Level.ERROR, "caf\u00e9")); + + MimeMessage message = onlySentMessage(); + assertEquals("caf\u00e9", message.getSubject()); + assertEquals("=?ISO-8859-1?Q?caf=E9?=", message.getHeader("Subject", null)); + } + @Test public void subjectIsTruncatedAtTheFirstNewLine() throws Exception { configure(appender); @@ -635,6 +726,18 @@ public void subjectIsTruncatedAtTheFirstNewLine() throws Exception { assertEquals("first line", onlySentMessage().getSubject()); } + @Test + public void subjectStartingWithANewLineIsSentEmpty() throws Exception { + configure(appender); + appender.setSubject("%msg"); + appender.addTo("to@example.com"); + startForSending(appender); + + appender.append(event(Level.ERROR, "\nsecond line")); + + assertEquals("", onlySentMessage().getSubject()); + } + @Test public void subjectIsUndefinedWithoutASubjectLayout() throws Exception { configure(appender); From ac838383c35095af934bf634e7422a1e738a1be0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:51:02 +0000 Subject: [PATCH 17/79] test: cover core.net.server socket appenders and core.net.ssl factories Close the unit-test coverage gaps of the server socket appenders, the concurrent server runner, the stream client and the SSL socket, context and key store factories. The tests pin: - ServerSocketAppenderBase (its test exercised AbstractServerSocketAppender instead) and AbstractServerSocketAppender: listener settings and their forwarding to the socket factory, the default factory/listener/runner, start/stop failures reported as errors, null events ignored, events transformed and offered to every client, the client queue size - ConcurrentServerRunner: clients tracked only while they run, a failing visitor reported without skipping other clients, clients that can't be configured or executed dropped and closed, interrupt ends the loop, closing a client task closes the client - RemoteReceiverStreamClient: offer without a queue, the stream reset every OOS_RESET_FREQUENCY events, socket/IO/runtime failures reported - ServerSocketListener: display of addresses with and without a host name - SSL: every ConfigurableSSL(Server)SocketFactory overload configures the delegate's socket, SSLConfigurable(Server)Socket delegation and the below-API-24 hostname verification guard, SSLParametersConfiguration's hostnameVerification getter and cached protocol/cipher suite choice, SSLNestedComponentRegistryRules' registrations, SSLContextFactoryBean's protocol/provider, default manager factories and JSSE system properties - KeyStoreFactoryBean: re-enable its ignored tests (they pass on the JVM) and pin every failure: missing location, unknown provider or type, missing file, wrong password, an unavailable integrity algorithm (via a test JCA provider) and a stream that fails to close Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../AbstractServerSocketAppenderTest.java | 258 ++++++++++++++ .../server/ConcurrentServerRunnerTest.java | 272 ++++++++++++++ .../RemoteReceiverStreamClientTest.java | 155 ++++++++ .../SSLServerSocketAppenderBaseTest.java | 39 ++ .../server/ServerSocketAppenderBaseTest.java | 336 ++++++++++++++++-- .../net/server/ServerSocketListenerTest.java | 39 ++ ...onfigurableSSLServerSocketFactoryTest.java | 104 ++++++ .../ssl/ConfigurableSSLSocketFactoryTest.java | 120 +++++++ .../core/net/ssl/KeyStoreFactoryBeanTest.java | 302 +++++++++++++++- .../ssl/SSLConfigurableServerSocketTest.java | 110 ++++++ .../net/ssl/SSLConfigurableSocketTest.java | 110 ++++++ ...SLContextFactoryBeanConfigurationTest.java | 234 ++++++++++++ .../SSLNestedComponentRegistryRulesTest.java | 65 ++++ .../ssl/SSLParametersConfigurationTest.java | 30 ++ 14 files changed, 2144 insertions(+), 30 deletions(-) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/net/ssl/ConfigurableSSLServerSocketFactoryTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/net/ssl/ConfigurableSSLSocketFactoryTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLConfigurableServerSocketTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLConfigurableSocketTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLContextFactoryBeanConfigurationTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLNestedComponentRegistryRulesTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/server/AbstractServerSocketAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/server/AbstractServerSocketAppenderTest.java index 454a2df53..43f987822 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/server/AbstractServerSocketAppenderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/server/AbstractServerSocketAppenderTest.java @@ -18,19 +18,43 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.Serializable; +import java.net.BindException; +import java.net.InetAddress; import java.net.ServerSocket; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.ScheduledExecutorService; + +import javax.net.ServerSocketFactory; import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.mockito.ArgumentCaptor; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.net.AbstractSocketAppender; import ch.qos.logback.core.net.mock.MockContext; +import ch.qos.logback.core.spi.PreSerializationTransformer; import ch.qos.logback.core.status.ErrorStatus; import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; /** * Unit tests for {@link AbstractServerSocketAppender}. @@ -51,16 +75,52 @@ public class AbstractServerSocketAppenderTest { private ServerSocket serverSocket; private InstrumentedServerSocketAppenderBase appender; + // collaborators of the RecordingServerSocketAppender tests below + private final ScheduledExecutorService executor = + mock(ScheduledExecutorService.class); + + private final ContextBase recordingContext = new ContextBase() { + @Override + public synchronized ScheduledExecutorService getScheduledExecutorService() { + return executor; + } + }; + + private final StatusChecker checker = new StatusChecker(recordingContext); + + private final ServerSocketFactory socketFactory = + mock(ServerSocketFactory.class); + + @SuppressWarnings("unchecked") + private final ServerRunner mockRunner = + mock(ServerRunner.class); + + private final RecordingServerSocketAppender recordingAppender = + new RecordingServerSocketAppender(); + + private InetAddress loopback; + private ServerSocket unboundSocket; + @Before public void setUp() throws Exception { serverSocket = ServerSocketUtil.createServerSocket(); appender = new InstrumentedServerSocketAppenderBase(serverSocket, listener, runner); appender.setContext(context); + + loopback = InetAddress.getByAddress(new byte[] { 127, 0, 0, 1 }); + unboundSocket = new ServerSocket(); + when(socketFactory.createServerSocket(anyInt(), anyInt(), any())) + .thenReturn(unboundSocket); + recordingAppender.setContext(recordingContext); } @After public void tearDown() throws Exception { serverSocket.close(); + unboundSocket.close(); + if (recordingAppender.lastSocket != null) { + recordingAppender.lastSocket.close(); + } } @Test @@ -102,4 +162,202 @@ public void testStopWhenNotStarted() throws Exception { assertEquals(0, runner.getStartCount()); } + @Test + public void listenerSettingsHaveDefaultsAndCanBeChanged() { + assertEquals(AbstractSocketAppender.DEFAULT_PORT, + recordingAppender.getPort()); + assertEquals(AbstractServerSocketAppender.DEFAULT_BACKLOG, + recordingAppender.getBacklog()); + assertEquals(AbstractServerSocketAppender.DEFAULT_CLIENT_QUEUE_SIZE, + recordingAppender.getClientQueueSize()); + assertNull(recordingAppender.getAddress()); + + recordingAppender.setPort(4561); + recordingAppender.setBacklog(7); + recordingAppender.setClientQueueSize(3); + recordingAppender.setAddress("127.0.0.1"); + + assertEquals(4561, recordingAppender.getPort()); + assertEquals(7, recordingAppender.getBacklog()); + assertEquals(3, recordingAppender.getClientQueueSize()); + assertEquals("127.0.0.1", recordingAppender.getAddress()); + } + + @Test + public void defaultServerSocketFactoryIsThePlatformDefault() + throws Exception { + assertSame(ServerSocketFactory.getDefault(), + recordingAppender.getServerSocketFactory()); + } + + @Test + public void inetAddressIsNullWhenNoAddressIsSet() throws Exception { + assertNull(recordingAppender.getInetAddress()); + } + + @Test + public void inetAddressIsResolvedFromTheConfiguredAddress() throws Exception { + recordingAppender.setAddress("127.0.0.1"); + assertEquals(loopback, recordingAppender.getInetAddress()); + } + + @Test + public void startCreatesTheServerSocketFromTheListenerSettings() + throws Exception { + recordingAppender.socketFactory = socketFactory; + recordingAppender.setPort(4561); + recordingAppender.setBacklog(7); + recordingAppender.setAddress("127.0.0.1"); + + recordingAppender.start(); + + assertTrue(recordingAppender.isStarted()); + verify(socketFactory).createServerSocket(4561, 7, loopback); + assertSame(unboundSocket, recordingAppender.lastSocket); + ArgumentCaptor command = ArgumentCaptor.forClass(Runnable.class); + verify(executor).execute(command.capture()); + assertTrue(command.getValue() instanceof RemoteReceiverServerRunner); + assertSame(recordingContext, + ((RemoteReceiverServerRunner) command.getValue()).getContext()); + } + + @Test + public void startListensOnTheConfiguredAddressAndStopClosesTheSocket() + throws Exception { + recordingAppender.setAddress("127.0.0.1"); + recordingAppender.setPort(0); + + recordingAppender.start(); + + assertTrue(recordingAppender.isStarted()); + assertTrue(recordingAppender.lastSocket.isBound()); + assertEquals(loopback, recordingAppender.lastSocket.getInetAddress()); + + recordingAppender.stop(); + + assertFalse(recordingAppender.isStarted()); + assertTrue(recordingAppender.lastSocket.isClosed()); + } + + @Test + public void startFailureIsReportedAndLeavesTheAppenderStopped() + throws Exception { + BindException ex = new BindException("Address already in use"); + when(socketFactory.createServerSocket(anyInt(), anyInt(), any())) + .thenThrow(ex); + recordingAppender.socketFactory = socketFactory; + + recordingAppender.start(); + + assertFalse(recordingAppender.isStarted()); + checker.assertContainsMatch(Status.ERROR, + "server startup error: java.net.BindException: Address already in use"); + assertTrue(checker.containsException(BindException.class)); + verifyNoInteractions(executor); + } + + @Test + public void appendIgnoresNullEvents() throws Exception { + recordingAppender.socketFactory = socketFactory; + recordingAppender.runner = mockRunner; + recordingAppender.start(); + + recordingAppender.doAppend(null); + + assertTrue(recordingAppender.postProcessed.isEmpty()); + verify(mockRunner, never()).accept(any()); + checker.assertIsErrorFree(); + } + + @Test + public void appendOffersTheTransformedEventToEachClient() throws Exception { + final RemoteReceiverClient client1 = mock(RemoteReceiverClient.class); + final RemoteReceiverClient client2 = mock(RemoteReceiverClient.class); + doAnswer(invocation -> { + ClientVisitor visitor = invocation.getArgument(0); + visitor.visit(client1); + visitor.visit(client2); + return null; + }).when(mockRunner).accept(any()); + recordingAppender.socketFactory = socketFactory; + recordingAppender.runner = mockRunner; + recordingAppender.start(); + + recordingAppender.doAppend("event"); + + assertEquals(1, recordingAppender.postProcessed.size()); + assertEquals("event", recordingAppender.postProcessed.get(0)); + verify(client1).offer("serialized event"); + verify(client2).offer("serialized event"); + } + + @Test + public void defaultServerRunnerGivesEachClientAQueueOfTheConfiguredSize() { + recordingAppender.setClientQueueSize(2); + @SuppressWarnings("unchecked") + ServerListener serverListener = + mock(ServerListener.class); + + ServerRunner defaultRunner = + recordingAppender.createServerRunner(serverListener, executor); + + assertTrue(defaultRunner instanceof RemoteReceiverServerRunner); + RemoteReceiverStreamClient client = + new RemoteReceiverStreamClient("id", new ByteArrayOutputStream()); + assertTrue(((RemoteReceiverServerRunner) defaultRunner) + .configureClient(client)); + assertTrue(client.offer("1")); + assertTrue(client.offer("2")); + assertFalse(client.offer("3")); + } + + /** + * An {@link AbstractServerSocketAppender} that records what it is given, + * and uses the base class's socket factory and server runner unless a + * replacement is set. + */ + private static class RecordingServerSocketAppender + extends AbstractServerSocketAppender { + + final List postProcessed = new ArrayList(); + ServerSocketFactory socketFactory; + ServerRunner runner; + ServerSocket lastSocket; + + @Override + protected void postProcessEvent(String event) { + postProcessed.add(event); + } + + @Override + protected PreSerializationTransformer getPST() { + return new PreSerializationTransformer() { + @Override + public Serializable transform(String event) { + return "serialized " + event; + } + }; + } + + @Override + protected ServerSocketFactory getServerSocketFactory() throws Exception { + return socketFactory != null + ? socketFactory : super.getServerSocketFactory(); + } + + @Override + protected ServerListener createServerListener( + ServerSocket socket) { + lastSocket = socket; + return super.createServerListener(socket); + } + + @Override + protected ServerRunner createServerRunner( + ServerListener listener, Executor executor) { + return runner != null + ? runner : super.createServerRunner(listener, executor); + } + } + } diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/server/ConcurrentServerRunnerTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/server/ConcurrentServerRunnerTest.java index 3355dee07..9c954e479 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/server/ConcurrentServerRunnerTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/server/ConcurrentServerRunnerTest.java @@ -15,14 +15,24 @@ */ package ch.qos.logback.core.net.server; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; @@ -32,7 +42,10 @@ import org.junit.Before; import org.junit.Test; +import ch.qos.logback.core.ContextBase; import ch.qos.logback.core.net.mock.MockContext; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; public class ConcurrentServerRunnerTest { @@ -47,6 +60,18 @@ public class ConcurrentServerRunnerTest { private InstrumentedConcurrentServerRunner runner = new InstrumentedConcurrentServerRunner(listener, executor); + // collaborators of the tests below that call run() on the test thread + private final ContextBase statusContext = new ContextBase(); + private final StatusChecker checker = new StatusChecker(statusContext); + + @SuppressWarnings("unchecked") + private final ServerListener scriptedListener = + mock(ServerListener.class); + + /** Tasks handed to {@link #capturingExecutor}, which doesn't run them. */ + private final List submitted = new ArrayList(); + private final Executor capturingExecutor = submitted::add; + @Before public void setUp() throws Exception { runner.setContext(context); @@ -54,6 +79,8 @@ public void setUp() throws Exception { @After public void tearDown() throws Exception { + // clear an interrupt a failed test may have left on the test thread + Thread.interrupted(); executor.shutdownNow(); assertTrue(executor.awaitTermination(DELAY, TimeUnit.MILLISECONDS)); } @@ -127,6 +154,251 @@ public void testRunClientAndVisit() throws Exception { runner.stop(); } + @Test + public void runExecutesEachAcceptedClientAndTracksItOnlyWhileItRuns() + throws Exception { + final SelectiveServerRunner selective = + new SelectiveServerRunner(scriptedListener, new Executor() { + @Override + public void execute(Runnable command) { + command.run(); + } + }); + selective.setContext(statusContext); + final RecordingClient client = new RecordingClient("client-1"); + final List visitedWhileRunning = + new ArrayList(); + final List runningStates = new ArrayList(); + client.onRun = () -> { + runningStates.add(selective.isRunning()); + selective.accept(visitedWhileRunning::add); + }; + when(scriptedListener.acceptClient()) + .thenReturn(client) + .thenThrow(new InterruptedException()); + + selective.run(); + + assertEquals(1, client.runCount); + assertEquals(Collections.singletonList(true), runningStates); + assertEquals(Collections.singletonList(client), visitedWhileRunning); + List visitedAfterwards = new ArrayList(); + selective.accept(visitedAfterwards::add); + assertTrue(visitedAfterwards.isEmpty()); + assertFalse(selective.isRunning()); + verify(scriptedListener).close(); + checker.assertContainsMatch(Status.INFO, "listening on "); + checker.assertContainsMatch(Status.INFO, "shutting down"); + checker.assertIsErrorFree(); + } + + @Test + public void visitorFailureIsReportedAndTheOtherClientsAreStillVisited() + throws Exception { + final SelectiveServerRunner selective = + new SelectiveServerRunner(scriptedListener, capturingExecutor); + selective.setContext(statusContext); + final RecordingClient first = new RecordingClient("client-1"); + final RecordingClient second = new RecordingClient("client-2"); + when(scriptedListener.acceptClient()) + .thenReturn(first, second) + .thenThrow(new InterruptedException()); + selective.run(); + assertEquals(2, submitted.size()); + + // run the second client while the first one runs, so both are tracked + // when the second one visits them + final List visited = new ArrayList(); + first.onRun = submitted.get(1); + second.onRun = () -> selective.accept(client -> { + if (client == first) { + throw new IllegalStateException("visit failed"); + } + visited.add(client); + }); + submitted.get(0).run(); + + assertEquals(Collections.singletonList(second), visited); + checker.assertContainsMatch(Status.ERROR, + "client-1: java.lang.IllegalStateException: visit failed"); + } + + @Test + public void clientThatCannotBeConfiguredIsDroppedAndClosed() + throws Exception { + SelectiveServerRunner selective = + new SelectiveServerRunner(scriptedListener, capturingExecutor); + selective.setContext(statusContext); + RecordingClient dropped = new RecordingClient("client-1"); + dropped.configurable = false; + RecordingClient accepted = new RecordingClient("client-2"); + when(scriptedListener.acceptClient()) + .thenReturn(dropped, accepted) + .thenThrow(new InterruptedException()); + + selective.run(); + + assertTrue(dropped.closed); + checker.assertContainsMatch(Status.ERROR, "client-1: connection dropped"); + checker.assertNoMatch("client-2: connection dropped"); + assertEquals(1, submitted.size()); + submitted.get(0).run(); + assertEquals(0, dropped.runCount); + assertEquals(1, accepted.runCount); + assertFalse(accepted.closed); + } + + @Test + public void clientRejectedByTheExecutorIsDroppedAndClosed() + throws Exception { + Executor rejectsFirstClient = new Executor() { + private boolean rejected; + + @Override + public void execute(Runnable command) { + if (!rejected) { + rejected = true; + throw new RejectedExecutionException("pool exhausted"); + } + submitted.add(command); + } + }; + SelectiveServerRunner selective = + new SelectiveServerRunner(scriptedListener, rejectsFirstClient); + selective.setContext(statusContext); + RecordingClient rejected = new RecordingClient("client-1"); + RecordingClient accepted = new RecordingClient("client-2"); + when(scriptedListener.acceptClient()) + .thenReturn(rejected, accepted) + .thenThrow(new InterruptedException()); + + selective.run(); + + assertTrue(rejected.closed); + checker.assertContainsMatch(Status.ERROR, "client-1: connection dropped"); + assertEquals(1, submitted.size()); + submitted.get(0).run(); + assertEquals(0, rejected.runCount); + assertEquals(1, accepted.runCount); + assertFalse(accepted.closed); + } + + @Test + public void interruptingTheRunnerThreadStopsAcceptingClients() + throws Exception { + SelectiveServerRunner selective = + new SelectiveServerRunner(scriptedListener, capturingExecutor); + selective.setContext(statusContext); + final RecordingClient client = new RecordingClient("client-1"); + when(scriptedListener.acceptClient()) + .thenAnswer(invocation -> { + Thread.currentThread().interrupt(); + return client; + }) + .thenThrow(new IOException("accepted after the interrupt")); + + try { + selective.run(); + assertTrue(Thread.currentThread().isInterrupted()); + } + finally { + Thread.interrupted(); + } + + verify(scriptedListener, times(1)).acceptClient(); + assertEquals(1, submitted.size()); + assertFalse(selective.isRunning()); + verify(scriptedListener).close(); + checker.assertContainsMatch(Status.INFO, "shutting down"); + checker.assertIsErrorFree(); + } + + @Test + public void listenerFailureIsReportedAndStopsTheRunner() throws Exception { + SelectiveServerRunner selective = + new SelectiveServerRunner(scriptedListener, capturingExecutor); + selective.setContext(statusContext); + when(scriptedListener.acceptClient()) + .thenThrow(new IOException("listener failed")); + + selective.run(); + + checker.assertContainsMatch(Status.ERROR, + "listener: java.io.IOException: listener failed"); + assertFalse(selective.isRunning()); + verify(scriptedListener).close(); + assertTrue(submitted.isEmpty()); + } + + @Test + public void closingAnExecutedClientTaskClosesTheClient() throws Exception { + SelectiveServerRunner selective = + new SelectiveServerRunner(scriptedListener, capturingExecutor); + selective.setContext(statusContext); + RecordingClient client = new RecordingClient("client-1"); + when(scriptedListener.acceptClient()) + .thenReturn(client) + .thenThrow(new InterruptedException()); + selective.run(); + assertEquals(1, submitted.size()); + assertTrue(submitted.get(0) instanceof Client); + + ((Client) submitted.get(0)).close(); + + assertTrue(client.closed); + assertEquals(0, client.runCount); + } + + /** + * A {@link ConcurrentServerRunner} that configures only the clients that + * are {@link RecordingClient#configurable}. + */ + private static class SelectiveServerRunner + extends ConcurrentServerRunner { + + SelectiveServerRunner(ServerListener listener, + Executor executor) { + super(listener, executor); + } + + @Override + protected boolean configureClient(RecordingClient client) { + return client.configurable; + } + } + + /** + * A {@link Client} that records how it is used, and whose {@link #run()} + * returns as soon as its {@link #onRun} task is done. + */ + private static class RecordingClient implements Client { + + private final String name; + boolean configurable = true; + Runnable onRun = () -> { }; + int runCount; + boolean closed; + + RecordingClient(String name) { + this.name = name; + } + + @Override + public void run() { + runCount++; + onRun.run(); + } + + @Override + public void close() { + closed = true; + } + + @Override + public String toString() { + return name; + } + } static class InstrumentedConcurrentServerRunner extends ConcurrentServerRunner { diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/server/RemoteReceiverStreamClientTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/server/RemoteReceiverStreamClientTest.java index 74ddfc4f4..7fcdbf106 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/server/RemoteReceiverStreamClientTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/server/RemoteReceiverStreamClientTest.java @@ -17,15 +17,29 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.ObjectInputStream; +import java.io.OutputStream; +import java.io.Serializable; +import java.net.SocketException; +import java.util.concurrent.LinkedBlockingQueue; +import org.junit.After; import org.junit.Before; import org.junit.Test; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.CoreConstants; import ch.qos.logback.core.net.mock.MockContext; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; /** * Unit tests for {@link RemoteReceiverStreamClient}. @@ -46,12 +60,22 @@ public class RemoteReceiverStreamClientTest { private RemoteReceiverStreamClient client = new RemoteReceiverStreamClient("someId", outputStream); + private final ContextBase statusContext = new ContextBase(); + private final StatusChecker checker = new StatusChecker(statusContext); + @Before public void setUp() throws Exception { client.setContext(context); client.setQueue(queue); } + @After + public void tearDown() { + // run() on the test thread ends by restoring the interrupt it got from + // the drained MockEventQueue; don't leak it into other tests + Thread.interrupted(); + } + @Test public void testOfferEventAndRun() throws Exception { client.offer(TEST_EVENT); @@ -86,4 +110,135 @@ public void testOfferEventSequenceAndRun() throws Exception { } } + @Test + public void offerWithoutAQueueIsRejected() { + final RemoteReceiverStreamClient unqueued = + new RemoteReceiverStreamClient("someId", outputStream); + + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> unqueued.offer(TEST_EVENT)); + + assertEquals("client has no event queue", ex.getMessage()); + } + + @Test + public void streamIsResetAfterEveryOosResetFrequencyEvents() + throws Exception { + final int frequency = CoreConstants.OOS_RESET_FREQUENCY; + // the same instance each time, so the stream writes back-references to + // it until the stream is reset + for (int i = 0; i < frequency + 2; i++) { + assertTrue(client.offer(TEST_EVENT)); + } + + client.run(); + assertTrue(Thread.interrupted()); + + ObjectInputStream ois = new ObjectInputStream( + new ByteArrayInputStream(outputStream.toByteArray())); + Object[] events = new Object[frequency + 2]; + for (int i = 0; i < events.length; i++) { + events[i] = ois.readObject(); + assertEquals(TEST_EVENT, events[i]); + } + assertSame(events[0], events[frequency - 1]); + // the reset after the frequency-th event makes it write the event anew + assertNotSame(events[frequency - 1], events[frequency]); + // and then counting starts over, so the next one is a back-reference + assertSame(events[frequency], events[frequency + 1]); + } + + @Test + public void socketExceptionClosesTheClientWithAnInfoStatus() { + RemoteReceiverStreamClient failing = new RemoteReceiverStreamClient( + "someId", new FailingOutputStream( + new SocketException("Connection reset"))); + failing.setContext(statusContext); + failing.setQueue(queue); + + failing.run(); + + checker.assertContainsMatch(Status.INFO, + "client someId: java.net.SocketException: Connection reset"); + checker.assertContainsMatch(Status.INFO, + "client someId: connection closed"); + checker.assertIsErrorFree(); + } + + @Test + public void ioExceptionClosesTheClientAndStreamWithAnErrorStatus() { + CloseRecordingOutputStream stream = new CloseRecordingOutputStream(); + RemoteReceiverStreamClient failing = + new RemoteReceiverStreamClient("someId", stream); + failing.setContext(statusContext); + failing.setQueue(queue); + failing.offer(new UnserializableEvent()); + + failing.run(); + + checker.assertContainsMatch(Status.ERROR, + "client someId: java.io.NotSerializableException: java.lang.Object"); + checker.assertContainsMatch(Status.INFO, + "client someId: connection closed"); + assertTrue(stream.closed); + } + + @Test + public void runtimeExceptionClosesTheClientWithAnErrorStatus() { + RemoteReceiverStreamClient failing = + new RemoteReceiverStreamClient("someId", outputStream); + failing.setContext(statusContext); + failing.setQueue(new LinkedBlockingQueue() { + private static final long serialVersionUID = 1L; + + @Override + public Serializable take() { + throw new IllegalStateException("queue broken"); + } + }); + + failing.run(); + + checker.assertContainsMatch(Status.ERROR, + "client someId: java.lang.IllegalStateException: queue broken"); + checker.assertContainsMatch(Status.INFO, + "client someId: connection closed"); + } + + /** An output stream whose every write fails with the given exception. */ + private static class FailingOutputStream extends OutputStream { + + private final IOException failure; + + FailingOutputStream(IOException failure) { + this.failure = failure; + } + + @Override + public void write(int b) throws IOException { + throw failure; + } + } + + /** An output stream that records whether it was closed. */ + private static class CloseRecordingOutputStream + extends ByteArrayOutputStream { + + boolean closed; + + @Override + public void close() { + closed = true; + } + } + + /** An event with a field that cannot be serialized. */ + private static class UnserializableEvent implements Serializable { + + private static final long serialVersionUID = 1L; + + @SuppressWarnings("unused") + private final Object payload = new Object(); + } + } diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/server/SSLServerSocketAppenderBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/server/SSLServerSocketAppenderBaseTest.java index 275107ecf..bd69b406d 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/server/SSLServerSocketAppenderBaseTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/server/SSLServerSocketAppenderBaseTest.java @@ -15,13 +15,23 @@ */ package ch.qos.logback.core.net.server; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.security.NoSuchAlgorithmException; import org.junit.Before; import org.junit.Test; import ch.qos.logback.core.net.mock.MockContext; +import ch.qos.logback.core.net.ssl.SSLConfiguration; import ch.qos.logback.core.spi.PreSerializationTransformer; +import ch.qos.logback.core.status.ErrorStatus; +import ch.qos.logback.core.status.Status; import ch.qos.logback.core.util.ExecutorServiceUtil; /** @@ -49,6 +59,35 @@ public void testUsingDefaultConfig() throws Exception { appender.stop(); } + @Test + public void defaultSslConfigurationIsCreatedOnceAndCanBeReplaced() { + SSLConfiguration defaultSsl = appender.getSsl(); + assertNotNull(defaultSsl); + assertSame(defaultSsl, appender.getSsl()); + + SSLConfiguration ssl = new SSLConfiguration(); + appender.setSsl(ssl); + + assertSame(ssl, appender.getSsl()); + } + + @Test + public void sslSetupFailureIsReportedAndLeavesTheAppenderStopped() { + SSLConfiguration ssl = new SSLConfiguration(); + ssl.setProtocol("A_FAKE_PROTOCOL_NAME"); + appender.setSsl(ssl); + + appender.start(); + + assertFalse(appender.isStarted()); + assertNull(appender.getServerSocketFactory()); + Status status = context.getLastStatus(); + assertTrue(status instanceof ErrorStatus); + assertTrue(status.getThrowable() instanceof NoSuchAlgorithmException); + assertEquals(status.getThrowable().getMessage(), status.getMessage()); + assertTrue(status.getMessage().contains("A_FAKE_PROTOCOL_NAME")); + } + private static class InstrumentedSSLServerSocketAppenderBase extends SSLServerSocketAppenderBase { diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/server/ServerSocketAppenderBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/server/ServerSocketAppenderBaseTest.java index 218b5d885..8e3682bd0 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/server/ServerSocketAppenderBaseTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/server/ServerSocketAppenderBaseTest.java @@ -17,19 +17,43 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.Serializable; +import java.net.BindException; +import java.net.InetAddress; import java.net.ServerSocket; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.ScheduledExecutorService; + +import javax.net.ServerSocketFactory; import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.mockito.ArgumentCaptor; -import ch.qos.logback.core.status.ErrorStatus; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.net.AbstractSocketAppender; +import ch.qos.logback.core.spi.PreSerializationTransformer; import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; /** * Unit tests for {@link ServerSocketAppenderBase}. @@ -38,67 +62,323 @@ */ public class ServerSocketAppenderBaseTest { + private final ScheduledExecutorService executor = + mock(ScheduledExecutorService.class); + + private final ContextBase context = new ContextBase() { + @Override + public synchronized ScheduledExecutorService getScheduledExecutorService() { + return executor; + } + }; + + private final StatusChecker checker = new StatusChecker(context); - private MockContext context = new MockContext(); + private final ServerSocketFactory socketFactory = + mock(ServerSocketFactory.class); - private MockServerRunner runner = - new MockServerRunner(); + @SuppressWarnings("unchecked") + private final ServerRunner runner = + mock(ServerRunner.class); - private MockServerListener listener = - new MockServerListener(); + private final RecordingServerSocketAppender appender = + new RecordingServerSocketAppender(); + private InetAddress loopback; private ServerSocket serverSocket; - private InstrumentedServerSocketAppenderBase appender; @Before public void setUp() throws Exception { - serverSocket = ServerSocketUtil.createServerSocket(); - appender = new InstrumentedServerSocketAppenderBase(serverSocket, listener, runner); + loopback = InetAddress.getByAddress(new byte[] { 127, 0, 0, 1 }); + serverSocket = new ServerSocket(); + when(socketFactory.createServerSocket(anyInt(), anyInt(), any())) + .thenReturn(serverSocket); appender.setContext(context); } @After public void tearDown() throws Exception { serverSocket.close(); + if (appender.lastSocket != null) { + appender.lastSocket.close(); + } + } + + @Test + public void listenerSettingsHaveDefaultsAndCanBeChanged() { + assertEquals(AbstractSocketAppender.DEFAULT_PORT, appender.getPort()); + assertEquals(ServerSocketAppenderBase.DEFAULT_BACKLOG, + appender.getBacklog().intValue()); + assertEquals(ServerSocketAppenderBase.DEFAULT_CLIENT_QUEUE_SIZE, + appender.getClientQueueSize()); + assertNull(appender.getAddress()); + + appender.setPort(4561); + appender.setBacklog(7); + appender.setClientQueueSize(3); + appender.setAddress("127.0.0.1"); + + assertEquals(4561, appender.getPort()); + assertEquals(7, appender.getBacklog().intValue()); + assertEquals(3, appender.getClientQueueSize()); + assertEquals("127.0.0.1", appender.getAddress()); + } + + @Test + public void defaultServerSocketFactoryIsThePlatformDefault() + throws Exception { + assertSame(ServerSocketFactory.getDefault(), + appender.getServerSocketFactory()); + } + + @Test + public void inetAddressIsNullWhenNoAddressIsSet() throws Exception { + assertNull(appender.getInetAddress()); + } + + @Test + public void inetAddressIsResolvedFromTheConfiguredAddress() throws Exception { + appender.setAddress("127.0.0.1"); + assertEquals(loopback, appender.getInetAddress()); + } + + @Test + public void startCreatesTheServerSocketFromTheListenerSettings() + throws Exception { + appender.socketFactory = socketFactory; + appender.setPort(4561); + appender.setBacklog(7); + appender.setAddress("127.0.0.1"); + + appender.start(); + + assertTrue(appender.isStarted()); + verify(socketFactory).createServerSocket(4561, 7, loopback); + assertSame(serverSocket, appender.lastSocket); + assertTrue(appender.lastListener instanceof RemoteReceiverServerListener); + assertSame(executor, appender.lastExecutor); } @Test - public void testStartStop() throws Exception { + public void startRunsTheDefaultServerRunnerOnTheContextExecutor() + throws Exception { + appender.socketFactory = socketFactory; + appender.start(); - assertTrue(runner.isContextInjected()); - assertTrue(runner.isRunning()); - assertSame(listener, appender.getLastListener()); + + ArgumentCaptor command = ArgumentCaptor.forClass(Runnable.class); + verify(executor).execute(command.capture()); + assertTrue(command.getValue() instanceof RemoteReceiverServerRunner); + RemoteReceiverServerRunner defaultRunner = + (RemoteReceiverServerRunner) command.getValue(); + assertSame(context, defaultRunner.getContext()); + assertFalse(defaultRunner.isRunning()); + } + + @Test + public void startListensOnTheConfiguredAddressAndStopClosesTheSocket() + throws Exception { + appender.setAddress("127.0.0.1"); + appender.setPort(0); + + appender.start(); + + assertTrue(appender.isStarted()); + assertTrue(appender.lastSocket.isBound()); + assertEquals(loopback, appender.lastSocket.getInetAddress()); appender.stop(); - assertFalse(runner.isRunning()); + + assertFalse(appender.isStarted()); + assertTrue(appender.lastSocket.isClosed()); } @Test - public void testStartWhenAlreadyStarted() throws Exception { + public void startWhenAlreadyStartedDoesNothing() throws Exception { + appender.socketFactory = socketFactory; + appender.runner = runner; + appender.start(); appender.start(); - assertEquals(1, runner.getStartCount()); + + verify(socketFactory, times(1)) + .createServerSocket(anyInt(), anyInt(), any()); + verify(executor, times(1)).execute(runner); } @Test - public void testStopThrowsException() throws Exception { + public void startHandsTheContextToTheRunnerBeforeExecutingIt() + throws Exception { + appender.socketFactory = socketFactory; + appender.runner = runner; + appender.start(); - assertTrue(appender.isStarted()); - IOException ex = new IOException("test exception"); - runner.setStopException(ex); + + verify(runner).setContext(context); + verify(executor).execute(runner); + } + + @Test + public void startFailureIsReportedAndLeavesTheAppenderStopped() + throws Exception { + BindException ex = new BindException("Address already in use"); + when(socketFactory.createServerSocket(anyInt(), anyInt(), any())) + .thenThrow(ex); + appender.socketFactory = socketFactory; + + appender.start(); + + assertFalse(appender.isStarted()); + checker.assertContainsMatch(Status.ERROR, + "server startup error: java.net.BindException: Address already in use"); + assertTrue(checker.containsException(BindException.class)); + verifyNoInteractions(executor); + } + + @Test + public void stopStopsTheServerRunner() throws Exception { + appender.socketFactory = socketFactory; + appender.runner = runner; + appender.start(); + + appender.stop(); + + verify(runner).stop(); + assertFalse(appender.isStarted()); + } + + @Test + public void stopWhenNotStartedDoesNothing() throws Exception { + appender.runner = runner; + appender.stop(); - Status status = context.getLastStatus(); - assertNotNull(status); - assertTrue(status instanceof ErrorStatus); - assertTrue(status.getMessage().contains(ex.getMessage())); - assertSame(ex, status.getThrowable()); + assertFalse(appender.isStarted()); + verifyNoInteractions(runner); } @Test - public void testStopWhenNotStarted() throws Exception { + public void stopFailureIsReportedAndLeavesTheAppenderStarted() + throws Exception { + IOException ex = new IOException("test exception"); + doThrow(ex).when(runner).stop(); + appender.socketFactory = socketFactory; + appender.runner = runner; + appender.start(); + appender.stop(); - assertEquals(0, runner.getStartCount()); + + assertTrue(appender.isStarted()); + checker.assertContainsMatch(Status.ERROR, + "server shutdown error: java.io.IOException: test exception"); + assertTrue(checker.containsException(IOException.class)); + } + + @Test + public void appendIgnoresNullEvents() throws Exception { + appender.socketFactory = socketFactory; + appender.runner = runner; + appender.start(); + + appender.doAppend(null); + + assertTrue(appender.postProcessed.isEmpty()); + verify(runner, never()).accept(any()); + checker.assertIsErrorFree(); + } + + @Test + public void appendOffersTheTransformedEventToEachClient() throws Exception { + final RemoteReceiverClient client1 = mock(RemoteReceiverClient.class); + final RemoteReceiverClient client2 = mock(RemoteReceiverClient.class); + doAnswer(invocation -> { + ClientVisitor visitor = invocation.getArgument(0); + visitor.visit(client1); + visitor.visit(client2); + return null; + }).when(runner).accept(any()); + appender.socketFactory = socketFactory; + appender.runner = runner; + appender.start(); + + appender.doAppend("event"); + + assertEquals(1, appender.postProcessed.size()); + assertEquals("event", appender.postProcessed.get(0)); + verify(client1).offer("serialized event"); + verify(client2).offer("serialized event"); + } + + @Test + public void defaultServerRunnerGivesEachClientAQueueOfTheConfiguredSize() { + appender.setClientQueueSize(2); + @SuppressWarnings("unchecked") + ServerListener listener = mock(ServerListener.class); + + ServerRunner defaultRunner = + appender.createServerRunner(listener, executor); + + assertTrue(defaultRunner instanceof RemoteReceiverServerRunner); + RemoteReceiverStreamClient client = + new RemoteReceiverStreamClient("id", new ByteArrayOutputStream()); + assertTrue(((RemoteReceiverServerRunner) defaultRunner) + .configureClient(client)); + assertTrue(client.offer("1")); + assertTrue(client.offer("2")); + assertFalse(client.offer("3")); + } + + /** + * A {@link ServerSocketAppenderBase} that records what it is given, and + * uses the base class's socket factory and server runner unless a + * replacement is set. + */ + private static class RecordingServerSocketAppender + extends ServerSocketAppenderBase { + + final List postProcessed = new ArrayList(); + ServerSocketFactory socketFactory; + ServerRunner runner; + ServerSocket lastSocket; + ServerListener lastListener; + Executor lastExecutor; + + @Override + protected void postProcessEvent(String event) { + postProcessed.add(event); + } + + @Override + protected PreSerializationTransformer getPST() { + return new PreSerializationTransformer() { + @Override + public Serializable transform(String event) { + return "serialized " + event; + } + }; + } + + @Override + protected ServerSocketFactory getServerSocketFactory() throws Exception { + return socketFactory != null + ? socketFactory : super.getServerSocketFactory(); + } + + @Override + protected ServerListener createServerListener( + ServerSocket socket) { + lastSocket = socket; + return super.createServerListener(socket); + } + + @Override + protected ServerRunner createServerRunner( + ServerListener listener, Executor executor) { + lastListener = listener; + lastExecutor = executor; + return runner != null + ? runner : super.createServerRunner(listener, executor); + } } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/server/ServerSocketListenerTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/server/ServerSocketListenerTest.java index 9e08c8ddc..88b652ee8 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/server/ServerSocketListenerTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/server/ServerSocketListenerTest.java @@ -15,14 +15,20 @@ */ package ch.qos.logback.core.net.server; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import java.io.IOException; import java.net.InetAddress; +import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.Socket; +import java.net.SocketAddress; +import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -47,6 +53,39 @@ public void setUp() throws Exception { listener = new InstrumentedServerSocketListener(serverSocket); } + @After + public void tearDown() throws Exception { + serverSocket.close(); + } + + @Test + public void toStringShowsTheLocalAddressWithoutTheHostNamePrefix() + throws Exception { + ServerSocket socket = mock(ServerSocket.class); + when(socket.getLocalSocketAddress()).thenReturn(new InetSocketAddress( + InetAddress.getByAddress("localhost", new byte[] { 127, 0, 0, 1 }), + 4560)); + + assertEquals("127.0.0.1:4560", + new InstrumentedServerSocketListener(socket).toString()); + } + + @Test + public void toStringShowsALocalAddressWithoutASlashUnchanged() { + ServerSocket socket = mock(ServerSocket.class); + when(socket.getLocalSocketAddress()).thenReturn(new SocketAddress() { + private static final long serialVersionUID = 1L; + + @Override + public String toString() { + return "local:4560"; + } + }); + + assertEquals("local:4560", + new InstrumentedServerSocketListener(socket).toString()); + } + @Test public void testAcceptClient() throws Exception { RunnableClient localClient = new RunnableClient( diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/ssl/ConfigurableSSLServerSocketFactoryTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/ssl/ConfigurableSSLServerSocketFactoryTest.java new file mode 100644 index 000000000..f08044fd2 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/net/ssl/ConfigurableSSLServerSocketFactoryTest.java @@ -0,0 +1,104 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.net.ssl; + +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.net.InetAddress; +import java.net.ServerSocket; + +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLServerSocketFactory; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.ContextBase; + +/** + * Unit tests for {@link ConfigurableSSLServerSocketFactory}. + */ +public class ConfigurableSSLServerSocketFactoryTest { + + private static final String[] PROTOCOLS = { "TLSv1.2", "TLSv1.3" }; + private static final String[] ENABLED_PROTOCOLS = { "TLSv1.3" }; + private static final String[] CIPHER_SUITES = { "SUITE_A", "SUITE_B" }; + private static final String[] ENABLED_CIPHER_SUITES = { "SUITE_B" }; + + private final SSLServerSocketFactory delegate = + mock(SSLServerSocketFactory.class); + private final SSLServerSocket socket = mock(SSLServerSocket.class); + private final SSLParametersConfiguration parameters = + new SSLParametersConfiguration(); + private final ConfigurableSSLServerSocketFactory factory = + new ConfigurableSSLServerSocketFactory(parameters, delegate); + + @Before + public void setUp() { + parameters.setContext(new ContextBase()); + parameters.setIncludedProtocols("TLSv1.3"); + parameters.setIncludedCipherSuites("SUITE_B"); + parameters.setNeedClientAuth(true); + when(socket.getSupportedProtocols()).thenReturn(PROTOCOLS); + when(socket.getEnabledProtocols()).thenReturn(PROTOCOLS); + when(socket.getSupportedCipherSuites()).thenReturn(CIPHER_SUITES); + when(socket.getEnabledCipherSuites()).thenReturn(CIPHER_SUITES); + } + + @Test + public void createServerSocketWithPortConfiguresTheDelegatesSocket() + throws Exception { + when(delegate.createServerSocket(4560)).thenReturn(socket); + + ServerSocket created = factory.createServerSocket(4560); + + assertSame(socket, created); + verifyConfigured(); + } + + @Test + public void createServerSocketWithPortAndBacklogConfiguresTheDelegatesSocket() + throws Exception { + when(delegate.createServerSocket(4560, 7)).thenReturn(socket); + + ServerSocket created = factory.createServerSocket(4560, 7); + + assertSame(socket, created); + verifyConfigured(); + } + + @Test + public void createServerSocketWithAddressConfiguresTheDelegatesSocket() + throws Exception { + InetAddress address = InetAddress.getByAddress(new byte[] { 127, 0, 0, 1 }); + when(delegate.createServerSocket(4560, 7, address)).thenReturn(socket); + + ServerSocket created = factory.createServerSocket(4560, 7, address); + + assertSame(socket, created); + verifyConfigured(); + } + + private void verifyConfigured() { + verify(socket).setEnabledProtocols(ENABLED_PROTOCOLS); + verify(socket).setEnabledCipherSuites(ENABLED_CIPHER_SUITES); + verify(socket).setNeedClientAuth(true); + } + +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/ssl/ConfigurableSSLSocketFactoryTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/ssl/ConfigurableSSLSocketFactoryTest.java new file mode 100644 index 000000000..1edba3d0c --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/net/ssl/ConfigurableSSLSocketFactoryTest.java @@ -0,0 +1,120 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.net.ssl; + +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.net.InetAddress; +import java.net.Socket; + +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.ContextBase; + +/** + * Unit tests for {@link ConfigurableSSLSocketFactory}. + */ +public class ConfigurableSSLSocketFactoryTest { + + private static final String[] PROTOCOLS = { "TLSv1.2", "TLSv1.3" }; + private static final String[] ENABLED_PROTOCOLS = { "TLSv1.3" }; + private static final String[] CIPHER_SUITES = { "SUITE_A", "SUITE_B" }; + private static final String[] ENABLED_CIPHER_SUITES = { "SUITE_B" }; + + private final SSLSocketFactory delegate = mock(SSLSocketFactory.class); + private final SSLSocket socket = mock(SSLSocket.class); + private final SSLParametersConfiguration parameters = + new SSLParametersConfiguration(); + private final ConfigurableSSLSocketFactory factory = + new ConfigurableSSLSocketFactory(parameters, delegate); + + private InetAddress remote; + private InetAddress local; + + @Before + public void setUp() throws Exception { + remote = InetAddress.getByAddress(new byte[] { 127, 0, 0, 2 }); + local = InetAddress.getByAddress(new byte[] { 127, 0, 0, 1 }); + parameters.setContext(new ContextBase()); + parameters.setIncludedProtocols("TLSv1.3"); + parameters.setIncludedCipherSuites("SUITE_B"); + parameters.setWantClientAuth(true); + when(socket.getSupportedProtocols()).thenReturn(PROTOCOLS); + when(socket.getEnabledProtocols()).thenReturn(PROTOCOLS); + when(socket.getSupportedCipherSuites()).thenReturn(CIPHER_SUITES); + when(socket.getEnabledCipherSuites()).thenReturn(CIPHER_SUITES); + } + + @Test + public void createSocketToAddressConfiguresTheDelegatesSocket() + throws Exception { + when(delegate.createSocket(remote, 4560)).thenReturn(socket); + + Socket created = factory.createSocket(remote, 4560); + + assertSame(socket, created); + verifyConfigured(); + } + + @Test + public void createSocketToAddressFromLocalPortConfiguresTheDelegatesSocket() + throws Exception { + when(delegate.createSocket(remote, 4560, local, 4561)).thenReturn(socket); + + Socket created = factory.createSocket(remote, 4560, local, 4561); + + assertSame(socket, created); + verifyConfigured(); + } + + @Test + public void createSocketToHostConfiguresTheDelegatesSocket() + throws Exception { + when(delegate.createSocket("logs.example.com", 4560)).thenReturn(socket); + + Socket created = factory.createSocket("logs.example.com", 4560); + + assertSame(socket, created); + verifyConfigured(); + } + + @Test + public void createSocketToHostFromLocalPortConfiguresTheDelegatesSocket() + throws Exception { + when(delegate.createSocket("logs.example.com", 4560, local, 4561)) + .thenReturn(socket); + + Socket created = + factory.createSocket("logs.example.com", 4560, local, 4561); + + assertSame(socket, created); + verifyConfigured(); + } + + private void verifyConfigured() { + verify(socket).setEnabledProtocols(ENABLED_PROTOCOLS); + verify(socket).setEnabledCipherSuites(ENABLED_CIPHER_SUITES); + verify(socket).setWantClientAuth(true); + } + +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/ssl/KeyStoreFactoryBeanTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/ssl/KeyStoreFactoryBeanTest.java index ab8904b25..7cca745d1 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/ssl/KeyStoreFactoryBeanTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/ssl/KeyStoreFactoryBeanTest.java @@ -15,23 +15,59 @@ */ package ch.qos.logback.core.net.ssl; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mockStatic; +import java.io.ByteArrayOutputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; +import java.security.Key; import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.KeyStoreSpi; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.Provider; +import java.security.Security; +import java.security.cert.Certificate; +import java.util.Date; +import java.util.Enumeration; -import org.junit.Ignore; +import org.junit.After; import org.junit.Test; +import org.mockito.MockedStatic; + +import ch.qos.logback.core.util.LocationUtil; /** * Unit tests for {@link KeyStoreFactoryBean}. * * @author Carl Harris */ -@Ignore() public class KeyStoreFactoryBeanTest { + private static final String FAILING_PROVIDER_NAME = + "KeyStoreFactoryBeanTestProvider"; + + private static final String FAILING_KEYSTORE_TYPE = "NoIntegrityAlgorithm"; + private KeyStoreFactoryBean factoryBean = new KeyStoreFactoryBean(); + @After + public void tearDown() { + Security.removeProvider(FAILING_PROVIDER_NAME); + } + @Test public void testDefaults() throws Exception { factoryBean.setLocation(SSLTestConstants.KEYSTORE_JKS_RESOURCE); @@ -67,4 +103,266 @@ public void testExplicitPassphrase() throws Exception { assertNotNull(factoryBean.createKeyStore()); } + @Test + public void typeAndPasswordHaveDefaultsAndProviderHasNone() { + assertNull(factoryBean.getLocation()); + assertEquals(SSL.DEFAULT_KEYSTORE_TYPE, factoryBean.getType()); + assertEquals(SSL.DEFAULT_KEYSTORE_PASSWORD, factoryBean.getPassword()); + assertNull(factoryBean.getProvider()); + } + + @Test + public void configuredPropertiesReplaceTheDefaults() { + factoryBean.setType(SSLTestConstants.PKCS12_TYPE); + factoryBean.setPassword("secret"); + factoryBean.setProvider("SomeProvider"); + + assertEquals(SSLTestConstants.PKCS12_TYPE, factoryBean.getType()); + assertEquals("secret", factoryBean.getPassword()); + assertEquals("SomeProvider", factoryBean.getProvider()); + } + + @Test + public void keyStoreIsLoadedFromTheConfiguredProviderAndType() + throws Exception { + factoryBean.setLocation(SSLTestConstants.KEYSTORE_PKCS12_RESOURCE); + factoryBean.setType(SSLTestConstants.PKCS12_TYPE); + String provider = KeyStore.getInstance(SSLTestConstants.PKCS12_TYPE) + .getProvider().getName(); + factoryBean.setProvider(provider); + + KeyStore keyStore = factoryBean.createKeyStore(); + + assertEquals(SSLTestConstants.PKCS12_TYPE, keyStore.getType()); + assertEquals(provider, keyStore.getProvider().getName()); + assertTrue(keyStore.size() > 0); + } + + @Test + public void locationIsRequired() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> factoryBean.createKeyStore()); + assertEquals("location is required", ex.getMessage()); + } + + @Test + public void unknownProviderIsReported() { + factoryBean.setLocation(SSLTestConstants.KEYSTORE_JKS_RESOURCE); + factoryBean.setProvider(SSLTestConstants.FAKE_PROVIDER_NAME); + + NoSuchProviderException ex = assertThrows(NoSuchProviderException.class, + () -> factoryBean.createKeyStore()); + + assertEquals("no such keystore provider: " + + SSLTestConstants.FAKE_PROVIDER_NAME, ex.getMessage()); + } + + @Test + public void keyStoreThatNeedsAnUnavailableAlgorithmIsReported() { + Security.addProvider(new FailingKeyStoreProvider()); + factoryBean.setLocation(SSLTestConstants.KEYSTORE_JKS_RESOURCE); + factoryBean.setProvider(FAILING_PROVIDER_NAME); + factoryBean.setType(FAILING_KEYSTORE_TYPE); + + NoSuchAlgorithmException ex = assertThrows(NoSuchAlgorithmException.class, + () -> factoryBean.createKeyStore()); + + assertEquals("no such keystore type: " + FAILING_KEYSTORE_TYPE, + ex.getMessage()); + } + + @Test + public void missingKeyStoreIsReportedAsFileNotFound() { + String location = "classpath:net/ssl/no-such-keystore.jks"; + factoryBean.setLocation(location); + + KeyStoreException ex = assertThrows(KeyStoreException.class, + () -> factoryBean.createKeyStore()); + + assertEquals(location + ": file not found", ex.getMessage()); + } + + @Test + public void unknownTypeIsReportedWithItsCause() { + factoryBean.setLocation(SSLTestConstants.KEYSTORE_JKS_RESOURCE); + factoryBean.setType(SSLTestConstants.FAKE_ALGORITHM_NAME); + + KeyStoreException ex = assertThrows(KeyStoreException.class, + () -> factoryBean.createKeyStore()); + + assertTrue(ex.getCause() instanceof KeyStoreException); + assertEquals(SSLTestConstants.KEYSTORE_JKS_RESOURCE + ": " + + ex.getCause().getMessage(), ex.getMessage()); + } + + @Test + public void wrongPasswordIsReportedWithItsCause() { + factoryBean.setLocation(SSLTestConstants.KEYSTORE_JKS_RESOURCE); + factoryBean.setPassword("not the password"); + + KeyStoreException ex = assertThrows(KeyStoreException.class, + () -> factoryBean.createKeyStore()); + + assertTrue(ex.getCause() instanceof IOException); + assertEquals(SSLTestConstants.KEYSTORE_JKS_RESOURCE + ": " + + ex.getCause().getMessage(), ex.getMessage()); + } + + @Test + @SuppressWarnings("deprecation") + public void failureToCloseTheKeyStoreStreamIsPrintedAndIgnored() + throws Exception { + final URL keyStoreUrl = + LocationUtil.urlForResource(SSLTestConstants.KEYSTORE_JKS_RESOURCE); + URL unclosableUrl = new URL("test", "", -1, "keystore.jks", + new URLStreamHandler() { + @Override + protected URLConnection openConnection(URL url) { + return new URLConnection(url) { + @Override + public void connect() { + } + + @Override + public InputStream getInputStream() throws IOException { + return new FilterInputStream(keyStoreUrl.openStream()) { + @Override + public void close() throws IOException { + super.close(); + throw new IOException("close failed"); + } + }; + } + }; + } + }); + factoryBean.setLocation("test:keystore.jks"); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + PrintStream originalStderr = System.err; + KeyStore keyStore; + try (MockedStatic locationUtil = + mockStatic(LocationUtil.class)) { + locationUtil.when(() -> LocationUtil.urlForResource("test:keystore.jks")) + .thenReturn(unclosableUrl); + System.setErr(new PrintStream(stderr, true, "UTF-8")); + + keyStore = factoryBean.createKeyStore(); + } + finally { + System.setErr(originalStderr); + } + + assertTrue(keyStore.size() > 0); + assertTrue(stderr.toString("UTF-8") + .contains("java.io.IOException: close failed")); + } + + /** + * A JCA provider of a key store type whose integrity check needs an + * algorithm that is not available. + */ + private static class FailingKeyStoreProvider extends Provider { + + private static final long serialVersionUID = 1L; + + @SuppressWarnings("deprecation") + FailingKeyStoreProvider() { + super(FAILING_PROVIDER_NAME, 1.0, + "key stores for KeyStoreFactoryBeanTest"); + put("KeyStore." + FAILING_KEYSTORE_TYPE, + NoIntegrityAlgorithmKeyStoreSpi.class.getName()); + } + } + + /** + * A key store that cannot be loaded because its integrity check algorithm + * is not available. Public, so that {@link Provider} can instantiate it. + */ + public static class NoIntegrityAlgorithmKeyStoreSpi extends KeyStoreSpi { + + @Override + public void engineLoad(InputStream stream, char[] password) + throws NoSuchAlgorithmException { + throw new NoSuchAlgorithmException( + "integrity check algorithm unavailable"); + } + + @Override + public Key engineGetKey(String alias, char[] password) { + throw new UnsupportedOperationException(); + } + + @Override + public Certificate[] engineGetCertificateChain(String alias) { + throw new UnsupportedOperationException(); + } + + @Override + public Certificate engineGetCertificate(String alias) { + throw new UnsupportedOperationException(); + } + + @Override + public Date engineGetCreationDate(String alias) { + throw new UnsupportedOperationException(); + } + + @Override + public void engineSetKeyEntry(String alias, Key key, char[] password, + Certificate[] chain) { + throw new UnsupportedOperationException(); + } + + @Override + public void engineSetKeyEntry(String alias, byte[] key, + Certificate[] chain) { + throw new UnsupportedOperationException(); + } + + @Override + public void engineSetCertificateEntry(String alias, Certificate cert) { + throw new UnsupportedOperationException(); + } + + @Override + public void engineDeleteEntry(String alias) { + throw new UnsupportedOperationException(); + } + + @Override + public Enumeration engineAliases() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean engineContainsAlias(String alias) { + throw new UnsupportedOperationException(); + } + + @Override + public int engineSize() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean engineIsKeyEntry(String alias) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean engineIsCertificateEntry(String alias) { + throw new UnsupportedOperationException(); + } + + @Override + public String engineGetCertificateAlias(Certificate cert) { + throw new UnsupportedOperationException(); + } + + @Override + public void engineStore(OutputStream stream, char[] password) { + throw new UnsupportedOperationException(); + } + } + } diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLConfigurableServerSocketTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLConfigurableServerSocketTest.java new file mode 100644 index 000000000..de3b5798f --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLConfigurableServerSocketTest.java @@ -0,0 +1,110 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.net.ssl; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import javax.net.ssl.SSLServerSocket; + +import android.os.Build; + +import org.junit.Test; + +/** + * Unit tests for {@link SSLConfigurableServerSocket}. + *

+ * The API 24+ hostname verification is covered by the Robolectric + * {@link SSLHostnameVerificationTest}. + */ +public class SSLConfigurableServerSocketTest { + + private static final String[] PROTOCOLS = { "TLSv1.2", "TLSv1.3" }; + private static final String[] CIPHER_SUITES = { "SUITE_A", "SUITE_B" }; + + private final SSLServerSocket socket = mock(SSLServerSocket.class); + private final SSLConfigurableServerSocket configurable = + new SSLConfigurableServerSocket(socket); + + @Test + public void defaultProtocolsAreTheSocketsEnabledProtocols() { + when(socket.getEnabledProtocols()).thenReturn(PROTOCOLS); + assertArrayEquals(PROTOCOLS, configurable.getDefaultProtocols()); + } + + @Test + public void supportedProtocolsAreTheSocketsSupportedProtocols() { + when(socket.getSupportedProtocols()).thenReturn(PROTOCOLS); + assertArrayEquals(PROTOCOLS, configurable.getSupportedProtocols()); + } + + @Test + public void enabledProtocolsAreSetOnTheSocket() { + configurable.setEnabledProtocols(PROTOCOLS); + verify(socket).setEnabledProtocols(PROTOCOLS); + } + + @Test + public void defaultCipherSuitesAreTheSocketsEnabledCipherSuites() { + when(socket.getEnabledCipherSuites()).thenReturn(CIPHER_SUITES); + assertArrayEquals(CIPHER_SUITES, configurable.getDefaultCipherSuites()); + } + + @Test + public void supportedCipherSuitesAreTheSocketsSupportedCipherSuites() { + when(socket.getSupportedCipherSuites()).thenReturn(CIPHER_SUITES); + assertArrayEquals(CIPHER_SUITES, configurable.getSupportedCipherSuites()); + } + + @Test + public void enabledCipherSuitesAreSetOnTheSocket() { + configurable.setEnabledCipherSuites(CIPHER_SUITES); + verify(socket).setEnabledCipherSuites(CIPHER_SUITES); + } + + @Test + public void needClientAuthIsSetOnTheSocket() { + configurable.setNeedClientAuth(true); + verify(socket).setNeedClientAuth(true); + } + + @Test + public void wantClientAuthIsSetOnTheSocket() { + configurable.setWantClientAuth(true); + verify(socket).setWantClientAuth(true); + } + + @Test + public void disabledHostnameVerificationLeavesTheSocketAlone() { + configurable.setHostnameVerification(false); + verifyNoInteractions(socket); + } + + @Test + public void hostnameVerificationLeavesTheSocketAloneBelowApi24() { + // plain JVM unit tests see the mockable android.jar's SDK_INT of 0 + assertTrue(Build.VERSION.SDK_INT < Build.VERSION_CODES.N); + + configurable.setHostnameVerification(true); + + verifyNoInteractions(socket); + } + +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLConfigurableSocketTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLConfigurableSocketTest.java new file mode 100644 index 000000000..ae4868072 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLConfigurableSocketTest.java @@ -0,0 +1,110 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.net.ssl; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import javax.net.ssl.SSLSocket; + +import android.os.Build; + +import org.junit.Test; + +/** + * Unit tests for {@link SSLConfigurableSocket}. + *

+ * The API 24+ hostname verification is covered by the Robolectric + * {@link SSLHostnameVerificationTest}. + */ +public class SSLConfigurableSocketTest { + + private static final String[] PROTOCOLS = { "TLSv1.2", "TLSv1.3" }; + private static final String[] CIPHER_SUITES = { "SUITE_A", "SUITE_B" }; + + private final SSLSocket socket = mock(SSLSocket.class); + private final SSLConfigurableSocket configurable = + new SSLConfigurableSocket(socket); + + @Test + public void defaultProtocolsAreTheSocketsEnabledProtocols() { + when(socket.getEnabledProtocols()).thenReturn(PROTOCOLS); + assertArrayEquals(PROTOCOLS, configurable.getDefaultProtocols()); + } + + @Test + public void supportedProtocolsAreTheSocketsSupportedProtocols() { + when(socket.getSupportedProtocols()).thenReturn(PROTOCOLS); + assertArrayEquals(PROTOCOLS, configurable.getSupportedProtocols()); + } + + @Test + public void enabledProtocolsAreSetOnTheSocket() { + configurable.setEnabledProtocols(PROTOCOLS); + verify(socket).setEnabledProtocols(PROTOCOLS); + } + + @Test + public void defaultCipherSuitesAreTheSocketsEnabledCipherSuites() { + when(socket.getEnabledCipherSuites()).thenReturn(CIPHER_SUITES); + assertArrayEquals(CIPHER_SUITES, configurable.getDefaultCipherSuites()); + } + + @Test + public void supportedCipherSuitesAreTheSocketsSupportedCipherSuites() { + when(socket.getSupportedCipherSuites()).thenReturn(CIPHER_SUITES); + assertArrayEquals(CIPHER_SUITES, configurable.getSupportedCipherSuites()); + } + + @Test + public void enabledCipherSuitesAreSetOnTheSocket() { + configurable.setEnabledCipherSuites(CIPHER_SUITES); + verify(socket).setEnabledCipherSuites(CIPHER_SUITES); + } + + @Test + public void needClientAuthIsSetOnTheSocket() { + configurable.setNeedClientAuth(true); + verify(socket).setNeedClientAuth(true); + } + + @Test + public void wantClientAuthIsSetOnTheSocket() { + configurable.setWantClientAuth(true); + verify(socket).setWantClientAuth(true); + } + + @Test + public void disabledHostnameVerificationLeavesTheSocketAlone() { + configurable.setHostnameVerification(false); + verifyNoInteractions(socket); + } + + @Test + public void hostnameVerificationLeavesTheSocketAloneBelowApi24() { + // plain JVM unit tests see the mockable android.jar's SDK_INT of 0 + assertTrue(Build.VERSION.SDK_INT < Build.VERSION_CODES.N); + + configurable.setHostnameVerification(true); + + verifyNoInteractions(socket); + } + +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLContextFactoryBeanConfigurationTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLContextFactoryBeanConfigurationTest.java new file mode 100644 index 000000000..3e19af723 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLContextFactoryBeanConfigurationTest.java @@ -0,0 +1,234 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.net.ssl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.Method; +import java.security.NoSuchProviderException; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.net.ssl.mock.MockContextAware; + +/** + * Plain JVM unit tests for the configuration properties of + * {@link SSLContextFactoryBean}, including those it takes from the JSSE + * system properties. {@link SSLContextFactoryBeanTest} covers creating a + * context from configured components. + */ +public class SSLContextFactoryBeanConfigurationTest { + + private static final String KEY_STORE = "javax.net.ssl.keyStore"; + private static final String TRUST_STORE = "javax.net.ssl.trustStore"; + private static final String[] JSSE_PROPERTIES = { + KEY_STORE, KEY_STORE + "Provider", KEY_STORE + "Password", + KEY_STORE + "Type", + TRUST_STORE, TRUST_STORE + "Provider", TRUST_STORE + "Password", + TRUST_STORE + "Type", + }; + + private final Map savedProperties = + new HashMap(); + + private final MockContextAware context = new MockContextAware(); + private final SSLContextFactoryBean factoryBean = new SSLContextFactoryBean(); + + @Before + public void setUp() { + for (String name : JSSE_PROPERTIES) { + savedProperties.put(name, System.getProperty(name)); + System.clearProperty(name); + } + } + + @After + public void tearDown() { + for (Map.Entry property : savedProperties.entrySet()) { + if (property.getValue() == null) { + System.clearProperty(property.getKey()); + } else { + System.setProperty(property.getKey(), property.getValue()); + } + } + } + + @Test + public void protocolDefaultsToSslAndCanBeChanged() { + assertEquals(SSL.DEFAULT_PROTOCOL, factoryBean.getProtocol()); + + factoryBean.setProtocol("TLS"); + + assertEquals("TLS", factoryBean.getProtocol()); + } + + @Test + public void providerIsUnsetByDefaultAndCanBeChanged() { + assertNull(factoryBean.getProvider()); + + factoryBean.setProvider("SomeProvider"); + + assertEquals("SomeProvider", factoryBean.getProvider()); + } + + @Test + public void contextIsCreatedForTheConfiguredProtocolAndProvider() + throws Exception { + String provider = SSLContext.getInstance("TLS").getProvider().getName(); + factoryBean.setProtocol("TLS"); + factoryBean.setProvider(provider); + + SSLContext sslContext = factoryBean.createContext(context); + + assertEquals("TLS", sslContext.getProtocol()); + assertEquals(provider, sslContext.getProvider().getName()); + assertTrue(context.hasInfoMatching( + "SSL protocol 'TLS' provider '" + Pattern.quote(provider) + ".*'")); + } + + @Test + public void unknownProviderIsReported() { + factoryBean.setProvider(SSLTestConstants.FAKE_PROVIDER_NAME); + + assertThrows(NoSuchProviderException.class, + () -> factoryBean.createContext(context)); + } + + @Test + public void defaultManagerFactoriesUseThePlatformDefaultAlgorithms() { + assertEquals(KeyManagerFactory.getDefaultAlgorithm(), + factoryBean.getKeyManagerFactory().getAlgorithm()); + assertEquals(TrustManagerFactory.getDefaultAlgorithm(), + factoryBean.getTrustManagerFactory().getAlgorithm()); + } + + @Test + public void configuredManagerFactoriesReplaceTheDefaults() { + KeyManagerFactoryFactoryBean keyManagerFactory = + new KeyManagerFactoryFactoryBean(); + TrustManagerFactoryFactoryBean trustManagerFactory = + new TrustManagerFactoryFactoryBean(); + + factoryBean.setKeyManagerFactory(keyManagerFactory); + factoryBean.setTrustManagerFactory(trustManagerFactory); + + assertSame(keyManagerFactory, factoryBean.getKeyManagerFactory()); + assertSame(trustManagerFactory, factoryBean.getTrustManagerFactory()); + } + + @Test + public void configuredStoresAreUsedWithTheDefaultManagerFactories() + throws Exception { + KeyStoreFactoryBean keyStore = new KeyStoreFactoryBean(); + keyStore.setLocation(SSLTestConstants.KEYSTORE_JKS_RESOURCE); + KeyStoreFactoryBean trustStore = new KeyStoreFactoryBean(); + trustStore.setLocation(SSLTestConstants.KEYSTORE_JKS_RESOURCE); + factoryBean.setKeyStore(keyStore); + factoryBean.setTrustStore(trustStore); + + assertNotNull(factoryBean.createContext(context)); + + assertTrue(context.hasInfoMatching("key manager algorithm '" + + Pattern.quote(KeyManagerFactory.getDefaultAlgorithm()) + "'.*")); + assertTrue(context.hasInfoMatching("trust manager algorithm '" + + Pattern.quote(TrustManagerFactory.getDefaultAlgorithm()) + "'.*")); + } + + @Test + public void storesAreUnsetWithoutJsseSystemProperties() { + assertNull(factoryBean.getKeyStore()); + assertNull(factoryBean.getTrustStore()); + } + + @Test + public void keyStoreIsTakenFromTheJsseSystemProperties() { + System.setProperty(KEY_STORE, "/data/keystore.p12"); + System.setProperty(KEY_STORE + "Provider", "KeyProvider"); + System.setProperty(KEY_STORE + "Password", "key secret"); + System.setProperty(KEY_STORE + "Type", "PKCS12"); + + KeyStoreFactoryBean keyStore = factoryBean.getKeyStore(); + + assertEquals("file:/data/keystore.p12", keyStore.getLocation()); + assertEquals("KeyProvider", keyStore.getProvider()); + assertEquals("key secret", keyStore.getPassword()); + assertEquals("PKCS12", keyStore.getType()); + assertSame(keyStore, factoryBean.getKeyStore()); + assertNull(factoryBean.getTrustStore()); + } + + @Test + public void trustStoreIsTakenFromTheJsseSystemProperties() { + System.setProperty(TRUST_STORE, "/data/truststore.jks"); + System.setProperty(TRUST_STORE + "Provider", "TrustProvider"); + System.setProperty(TRUST_STORE + "Password", "trust secret"); + System.setProperty(TRUST_STORE + "Type", "JKS"); + + KeyStoreFactoryBean trustStore = factoryBean.getTrustStore(); + + assertEquals("file:/data/truststore.jks", trustStore.getLocation()); + assertEquals("TrustProvider", trustStore.getProvider()); + assertEquals("trust secret", trustStore.getPassword()); + assertEquals("JKS", trustStore.getType()); + assertSame(trustStore, factoryBean.getTrustStore()); + assertNull(factoryBean.getKeyStore()); + } + + @Test + public void storeFromJsseSystemPropertiesUsesDefaultsForUnsetProperties() { + System.setProperty(KEY_STORE, "/data/keystore.jks"); + + KeyStoreFactoryBean keyStore = factoryBean.getKeyStore(); + + assertNull(keyStore.getProvider()); + assertEquals(SSL.DEFAULT_KEYSTORE_PASSWORD, keyStore.getPassword()); + assertEquals(SSL.DEFAULT_KEYSTORE_TYPE, keyStore.getType()); + } + + @Test + public void storeLocationThatIsAlreadyAFileUrlIsKept() { + System.setProperty(TRUST_STORE, "file:/data/truststore.jks"); + + assertEquals("file:/data/truststore.jks", + factoryBean.getTrustStore().getLocation()); + } + + @Test + public void locationOfAnUnsetSystemPropertyIsNull() throws Exception { + // getKeyStore() and getTrustStore() only ask for the location of a + // property that is set, so this can only be reached directly + Method locationFromSystemProperty = SSLContextFactoryBean.class + .getDeclaredMethod("locationFromSystemProperty", String.class); + locationFromSystemProperty.setAccessible(true); + + assertNull(locationFromSystemProperty.invoke(factoryBean, KEY_STORE)); + } + +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLNestedComponentRegistryRulesTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLNestedComponentRegistryRulesTest.java new file mode 100644 index 000000000..e467cb8f2 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLNestedComponentRegistryRulesTest.java @@ -0,0 +1,65 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.net.ssl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import org.junit.Test; + +import ch.qos.logback.core.joran.spi.DefaultNestedComponentRegistry; + +/** + * Unit tests for {@link SSLNestedComponentRegistryRules}. + */ +public class SSLNestedComponentRegistryRulesTest { + + private final DefaultNestedComponentRegistry registry = + new DefaultNestedComponentRegistry(); + + @Test + public void registersTheDefaultTypesOfTheSslConfigurationElements() { + SSLNestedComponentRegistryRules.addDefaultNestedComponentRegistryRules( + registry); + + assertEquals(SSLConfiguration.class, + registry.findDefaultComponentType(SSLComponent.class, "ssl")); + assertEquals(SSLParametersConfiguration.class, + registry.findDefaultComponentType(SSLConfiguration.class, + "parameters")); + assertEquals(KeyStoreFactoryBean.class, + registry.findDefaultComponentType(SSLConfiguration.class, "keyStore")); + assertEquals(KeyStoreFactoryBean.class, + registry.findDefaultComponentType(SSLConfiguration.class, + "trustStore")); + assertEquals(KeyManagerFactoryFactoryBean.class, + registry.findDefaultComponentType(SSLConfiguration.class, + "keyManagerFactory")); + assertEquals(TrustManagerFactoryFactoryBean.class, + registry.findDefaultComponentType(SSLConfiguration.class, + "trustManagerFactory")); + assertEquals(SecureRandomFactoryBean.class, + registry.findDefaultComponentType(SSLConfiguration.class, + "secureRandom")); + } + + @Test + public void rulesClassHasAPublicDefaultConstructor() { + // part of the public API, although all of its members are static + assertNotNull(new SSLNestedComponentRegistryRules()); + } + +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLParametersConfigurationTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLParametersConfigurationTest.java index bae982c97..198dcdc34 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLParametersConfigurationTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLParametersConfigurationTest.java @@ -15,6 +15,8 @@ */ package ch.qos.logback.core.net.ssl; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import java.util.Arrays; @@ -144,4 +146,32 @@ public void testPassDefaultWantClientAuth() throws Exception { assertTrue(configurable.isWantClientAuth()); } + @Test + public void hostnameVerificationIsFalseUntilConfigured() { + assertFalse(configuration.getHostnameVerification()); + + configuration.setHostnameVerification(true); + assertTrue(configuration.getHostnameVerification()); + + configuration.setHostnameVerification(false); + assertFalse(configuration.getHostnameVerification()); + } + + @Test + public void enabledProtocolsAndCipherSuitesAreDeterminedOnlyOnce() { + configurable.setDefaultProtocols(new String[] { "A" }); + configurable.setDefaultCipherSuites(new String[] { "X" }); + configuration.configure(configurable); + + // the same engine is assumed for every socket, so the defaults of a + // later socket don't matter + MockSSLConfigurable other = new MockSSLConfigurable(); + other.setDefaultProtocols(new String[] { "B" }); + other.setDefaultCipherSuites(new String[] { "Y" }); + configuration.configure(other); + + assertArrayEquals(new String[] { "A" }, other.getEnabledProtocols()); + assertArrayEquals(new String[] { "X" }, other.getEnabledCipherSuites()); + } + } From b353ef32ac2a5a2bb5b4090f8fca49a15e196b06 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:09:25 +0000 Subject: [PATCH 18/79] test: pin client-auth values and slash-prefixed listener addresses The SSLConfigurable*Socket client-auth tests only passed true, so a delegate call that ignored its argument survived; they now also pass false. ServerSocketListener's toString had no case for an address whose display string starts with the slash (i == 0), so shifting the boundary to i > 0 survived; add one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../core/net/server/ServerSocketListenerTest.java | 12 ++++++++++++ .../net/ssl/SSLConfigurableServerSocketTest.java | 6 ++++++ .../core/net/ssl/SSLConfigurableSocketTest.java | 6 ++++++ 3 files changed, 24 insertions(+) diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/server/ServerSocketListenerTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/server/ServerSocketListenerTest.java index 88b652ee8..d768fd004 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/server/ServerSocketListenerTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/server/ServerSocketListenerTest.java @@ -70,6 +70,18 @@ public void toStringShowsTheLocalAddressWithoutTheHostNamePrefix() new InstrumentedServerSocketListener(socket).toString()); } + @Test + public void toStringShowsALocalAddressWithoutAHostNameWithoutTheSlash() + throws Exception { + ServerSocket socket = mock(ServerSocket.class); + // an address with no host name prints as "/127.0.0.1:4560" + when(socket.getLocalSocketAddress()).thenReturn(new InetSocketAddress( + InetAddress.getByAddress(new byte[] { 127, 0, 0, 1 }), 4560)); + + assertEquals("127.0.0.1:4560", + new InstrumentedServerSocketListener(socket).toString()); + } + @Test public void toStringShowsALocalAddressWithoutASlashUnchanged() { ServerSocket socket = mock(ServerSocket.class); diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLConfigurableServerSocketTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLConfigurableServerSocketTest.java index de3b5798f..5da15cae1 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLConfigurableServerSocketTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLConfigurableServerSocketTest.java @@ -82,13 +82,19 @@ public void enabledCipherSuitesAreSetOnTheSocket() { @Test public void needClientAuthIsSetOnTheSocket() { configurable.setNeedClientAuth(true); + configurable.setNeedClientAuth(false); + verify(socket).setNeedClientAuth(true); + verify(socket).setNeedClientAuth(false); } @Test public void wantClientAuthIsSetOnTheSocket() { configurable.setWantClientAuth(true); + configurable.setWantClientAuth(false); + verify(socket).setWantClientAuth(true); + verify(socket).setWantClientAuth(false); } @Test diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLConfigurableSocketTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLConfigurableSocketTest.java index ae4868072..76c146521 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLConfigurableSocketTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/ssl/SSLConfigurableSocketTest.java @@ -82,13 +82,19 @@ public void enabledCipherSuitesAreSetOnTheSocket() { @Test public void needClientAuthIsSetOnTheSocket() { configurable.setNeedClientAuth(true); + configurable.setNeedClientAuth(false); + verify(socket).setNeedClientAuth(true); + verify(socket).setNeedClientAuth(false); } @Test public void wantClientAuthIsSetOnTheSocket() { configurable.setWantClientAuth(true); + configurable.setWantClientAuth(false); + verify(socket).setWantClientAuth(true); + verify(socket).setWantClientAuth(false); } @Test From 07f0c609b775e6ab6f510e037965284728c5a252 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:58:55 +0000 Subject: [PATCH 19/79] fix: make CyclicBuffer.resize() work on partially filled buffers resize() compared against the element count (numElems) where it meant the capacity (maxSize), in two places (inherited from upstream logback and log4j): - the "nothing to do" early return fired when newSize equalled the number of elements, so resizing a buffer of capacity 4 holding 2 elements to 2 was silently ignored and it kept holding up to 4; - the copy loop wrapped the read index at numElems instead of at the end of the backing array, so a buffer from which elements had been removed with get() lost elements (copied nulls) or threw ArrayIndexOutOfBoundsException once the index ran past numElems. Both checks now use maxSize. For a full buffer (numElems == maxSize) and for one never drained (first == 0) the result is unchanged. resize() has no caller in the library; the new tests reproduce each defect and pin the rest of resize(): negative size rejected, growing and shrinking a wrapped full buffer, resizing to zero. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../logback/core/helpers/CyclicBuffer.java | 7 +- .../core/helpers/CyclicBufferTest.java | 125 ++++++++++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/logback-android/src/main/java/ch/qos/logback/core/helpers/CyclicBuffer.java b/logback-android/src/main/java/ch/qos/logback/core/helpers/CyclicBuffer.java index 61228f910..96f17f91f 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/helpers/CyclicBuffer.java +++ b/logback-android/src/main/java/ch/qos/logback/core/helpers/CyclicBuffer.java @@ -156,7 +156,10 @@ public void resize(int newSize) { throw new IllegalArgumentException("Negative array size [" + newSize + "] not allowed."); } - if (newSize == numElems) + // Upstream compares with numElems here and in the loop below, which ignores a + // resize to the current length and wraps a partially filled buffer at the wrong + // index; the capacity (maxSize) is what both checks are about. + if (newSize == maxSize) return; // nothing to do // @@ -167,7 +170,7 @@ public void resize(int newSize) { for (int i = 0; i < loopLen; i++) { temp[i] = ea[first]; ea[first] = null; - if (++first == numElems) + if (++first == maxSize) first = 0; } ea = temp; diff --git a/logback-android/src/test/java/ch/qos/logback/core/helpers/CyclicBufferTest.java b/logback-android/src/test/java/ch/qos/logback/core/helpers/CyclicBufferTest.java index 94a627926..3798aa873 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/helpers/CyclicBufferTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/helpers/CyclicBufferTest.java @@ -21,6 +21,9 @@ import java.util.List; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; public class CyclicBufferTest { @@ -60,4 +63,126 @@ public void cloning() { assertEquals(witness, clone.asList()); } + + @Test + public void resizeRejectsNegativeSize() { + CyclicBuffer cb = new CyclicBuffer(2); + cb.add("a"); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> cb.resize(-1)); + assertEquals("Negative array size [-1] not allowed.", e.getMessage()); + // the buffer is left untouched + assertEquals(2, cb.getMaxSize()); + assertEquals(Arrays.asList("a"), cb.asList()); + } + + @Test + public void resizeToCurrentCapacityOfFullBufferKeepsBufferAsIs() { + CyclicBuffer cb = new CyclicBuffer(2); + cb.add("a"); + cb.add("b"); + cb.add("c"); + Object[] storage = cb.ea; + + cb.resize(2); + + assertSame(storage, cb.ea); + assertEquals(2, cb.getMaxSize()); + assertEquals(Arrays.asList("b", "c"), cb.asList()); + cb.add("d"); + assertEquals(Arrays.asList("c", "d"), cb.asList()); + } + + @Test + public void resizeToCurrentLengthShrinksCapacity() { + CyclicBuffer cb = new CyclicBuffer(4); + cb.add("a"); + cb.add("b"); + + cb.resize(2); + + assertEquals(2, cb.getMaxSize()); + assertEquals(Arrays.asList("a", "b"), cb.asList()); + cb.add("c"); + assertEquals(Arrays.asList("b", "c"), cb.asList()); + } + + @Test + public void growingAFullBufferKeepsElementsAndAppendsAfterThem() { + CyclicBuffer cb = new CyclicBuffer(3); + cb.add("a"); + cb.add("b"); + cb.add("c"); + cb.add("d"); + + cb.resize(5); + + assertEquals(5, cb.getMaxSize()); + assertEquals(Arrays.asList("b", "c", "d"), cb.asList()); + cb.add("e"); + cb.add("f"); + assertEquals(Arrays.asList("b", "c", "d", "e", "f"), cb.asList()); + cb.add("g"); + assertEquals(Arrays.asList("c", "d", "e", "f", "g"), cb.asList()); + } + + @Test + public void shrinkingAFullBufferKeepsTheOldestElements() { + CyclicBuffer cb = new CyclicBuffer(3); + cb.add("a"); + cb.add("b"); + cb.add("c"); + cb.add("d"); + + cb.resize(2); + + assertEquals(2, cb.getMaxSize()); + assertEquals(Arrays.asList("b", "c"), cb.asList()); + cb.add("e"); + assertEquals(Arrays.asList("c", "e"), cb.asList()); + } + + @Test + public void resizeAfterRemovalKeepsRemainingElementsInOrder() { + CyclicBuffer cb = new CyclicBuffer(4); + cb.add("a"); + cb.add("b"); + cb.add("c"); + assertEquals("a", cb.get()); + + cb.resize(3); + + assertEquals(3, cb.getMaxSize()); + assertEquals(Arrays.asList("b", "c"), cb.asList()); + cb.add("d"); + assertEquals(Arrays.asList("b", "c", "d"), cb.asList()); + } + + @Test + public void resizeOfWrappedPartiallyFilledBufferKeepsRemainingElementsInOrder() { + CyclicBuffer cb = new CyclicBuffer(4); + cb.add("a"); + cb.add("b"); + cb.add("c"); + cb.add("d"); + cb.add("e"); // overwrites "a", the oldest element is now at index 1 + assertEquals("b", cb.get()); + assertEquals("c", cb.get()); + // "d" is stored at index 3 and "e" at index 0 + + cb.resize(3); + + assertEquals(Arrays.asList("d", "e"), cb.asList()); + } + + @Test + public void resizeToZeroEmptiesTheBuffer() { + CyclicBuffer cb = new CyclicBuffer(3); + cb.add("a"); + + cb.resize(0); + + assertEquals(0, cb.getMaxSize()); + assertSize(cb, 0); + assertNull(cb.get()); + } } From 486ddd332bc4086d3bae4610235906e216a01c4d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:59:43 +0000 Subject: [PATCH 20/79] refactor: fold unreachable enum-switch defaults into the last case in subst Four switches over an enum in ch.qos.logback.core.subst handle every constant of the enum, so the implicit (or empty) default branch javac emits for them can never run, and JaCoCo reports it as a missed branch: - Node.toString() on Node.Type, followed by an unreachable "return null"; - NodeToStringTransformer.compileNode() on Node.Type; - Tokenizer.tokenize() on TokenizerState, twice (per character, and at the end of the input). Reaching them would take rewriting the compiler-generated switch map by reflection. Instead the last case label now shares its body with "default:", and Node.toString() loses its dead "return null". Behaviour is unchanged for every input: each constant still selects the same code, and a null type or state still throws NullPointerException from the switch itself, as before. NodeTest pins Node.toString() for both types, with and without a default part, and the NullPointerException for a node without a type; TokenizerTest, ParserTest and NodeToStringTransformerTest keep exercising every case of the other switches. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../java/ch/qos/logback/core/subst/Node.java | 2 +- .../core/subst/NodeToStringTransformer.java | 1 + .../ch/qos/logback/core/subst/Tokenizer.java | 3 +- .../ch/qos/logback/core/subst/NodeTest.java | 61 +++++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/subst/NodeTest.java diff --git a/logback-android/src/main/java/ch/qos/logback/core/subst/Node.java b/logback-android/src/main/java/ch/qos/logback/core/subst/Node.java index 331ee97bb..625b9f81a 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/subst/Node.java +++ b/logback-android/src/main/java/ch/qos/logback/core/subst/Node.java @@ -59,6 +59,7 @@ public String toString() { ", payload='" + payload + "'}"; case VARIABLE: + default: StringBuilder payloadBuf = new StringBuilder(); StringBuilder defaultPartBuf2 = new StringBuilder(); if (defaultPart != null) @@ -73,7 +74,6 @@ public String toString() { r += '}'; return r; } - return null; } public void dump() { diff --git a/logback-android/src/main/java/ch/qos/logback/core/subst/NodeToStringTransformer.java b/logback-android/src/main/java/ch/qos/logback/core/subst/NodeToStringTransformer.java index edb8488a8..3236550d2 100755 --- a/logback-android/src/main/java/ch/qos/logback/core/subst/NodeToStringTransformer.java +++ b/logback-android/src/main/java/ch/qos/logback/core/subst/NodeToStringTransformer.java @@ -71,6 +71,7 @@ private void compileNode(Node inputNode, StringBuilder stringBuilder, Stack tokenize() throws ScanException { handleStartState(c, tokenList, buf); break; case DEFAULT_VAL_STATE: - handleDefaultValueState(c, tokenList, buf); default: + handleDefaultValueState(c, tokenList, buf); } } // EOS @@ -67,6 +67,7 @@ List tokenize() throws ScanException { addLiteralToken(tokenList, buf); break; case START_STATE: + default: // trailing $. see also LOGBACK-1149 buf.append(CoreConstants.DOLLAR); addLiteralToken(tokenList, buf); diff --git a/logback-android/src/test/java/ch/qos/logback/core/subst/NodeTest.java b/logback-android/src/test/java/ch/qos/logback/core/subst/NodeTest.java new file mode 100644 index 000000000..8c58e129d --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/subst/NodeTest.java @@ -0,0 +1,61 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.subst; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class NodeTest { + + private static Node literal(String s) { + return new Node(Node.Type.LITERAL, s); + } + + @Test + public void literalToStringShowsPayload() { + assertEquals("Node{type=LITERAL, payload='abc'}", literal("abc").toString()); + } + + @Test + public void variableToStringWithoutDefaultPartShowsPayloadChain() { + Node node = new Node(Node.Type.VARIABLE, literal("k")); + assertEquals("Node{type=VARIABLE, payload='Node{type=LITERAL, payload='k'} --> null '}", node.toString()); + } + + @Test + public void variableToStringWithDefaultPartShowsBothChains() { + Node payload = literal("a"); + payload.next = literal("b"); + Node node = new Node(Node.Type.VARIABLE, payload, literal("x")); + + assertEquals("Node{type=VARIABLE, payload='Node{type=LITERAL, payload='a'} --> Node{type=LITERAL, payload='b'} --> null '" + + ", defaultPart=Node{type=LITERAL, payload='x'} --> null }", node.toString()); + } + + @Test + public void variableToStringWithoutPayloadShowsEmptyChain() { + Node node = new Node(Node.Type.VARIABLE, null); + assertEquals("Node{type=VARIABLE, payload='null '}", node.toString()); + } + + @Test + public void toStringOfNodeWithoutTypeFails() { + Node node = new Node(null, "a"); + assertThrows(NullPointerException.class, node::toString); + } +} From c7597f924b8fd6a62f491054f0960711378828b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:00:27 +0000 Subject: [PATCH 21/79] test: cover core.helpers and core.subst lines and branches Close the remaining JaCoCo gaps in CyclicBuffer, ThrowableToStringArray, Transform, Node, NodeToStringTransformer, Parser, Token and Tokenizer: - CyclicBuffer: constructor rejects a non-positive size, indexed get() returns null outside [0, length) even when the backing array is full, get() on an empty buffer returns null without corrupting the count. - ThrowableToStringArray: messageless throwables and causes, and causes whose frames are all shared with, or outnumber, the parent's frames (built from fixed stack traces and checked against printStackTrace). - Transform (new TransformTest): null, empty and safe input returned as is, every escaped character, kept tab/CR/LF, control characters replaced with U+FFFD, the StringBuffer overload escaping in place, and appendEscapingCDATA with no, one, several and a trailing "]]>". - Node (NodeTest) and Token (new TokenTest): constructors, setNext, recursive, equals for identity, null, other class and each field, hashCode formula and consistency with equals, toString. - Parser: a null token list parses to null, a second default separator and an unclosed variable are rejected with their messages. - NodeToStringTransformer: lookup order across both property containers, system properties and the environment (the latter via Mockito.mockStatic of OptionHelper.getEnv), undefined keys, circular references with and without default values, and the cycle check's node comparison (type, payload, default part, ignoring next). - Tokenizer: "$" not followed by "{" stays literal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../core/helpers/CyclicBufferTest.java | 79 +++++++++ .../helpers/ThrowableToStringArrayTest.java | 71 ++++++++ .../logback/core/helpers/TransformTest.java | 116 +++++++++++++ .../ch/qos/logback/core/subst/NodeTest.java | 153 ++++++++++++++++++ .../subst/NodeToStringTransformerTest.java | 140 ++++++++++++++++ .../ch/qos/logback/core/subst/ParserTest.java | 30 ++++ .../ch/qos/logback/core/subst/TokenTest.java | 84 ++++++++++ .../qos/logback/core/subst/TokenizerTest.java | 13 ++ 8 files changed, 686 insertions(+) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/helpers/TransformTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/subst/TokenTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/core/helpers/CyclicBufferTest.java b/logback-android/src/test/java/ch/qos/logback/core/helpers/CyclicBufferTest.java index 3798aa873..625c77410 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/helpers/CyclicBufferTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/helpers/CyclicBufferTest.java @@ -64,6 +64,85 @@ public void cloning() { } + @Test + public void constructorRejectsNonPositiveMaxSize() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> new CyclicBuffer(0)); + assertEquals("The maxSize argument (0) is not a positive integer.", e.getMessage()); + + e = assertThrows(IllegalArgumentException.class, () -> new CyclicBuffer(-3)); + assertEquals("The maxSize argument (-3) is not a positive integer.", e.getMessage()); + } + + @Test + public void constructorAcceptsMaxSizeOfOne() { + CyclicBuffer cb = new CyclicBuffer(1); + assertEquals(1, cb.getMaxSize()); + cb.add("a"); + cb.add("b"); + assertEquals(Arrays.asList("b"), cb.asList()); + } + + @Test + public void getMaxSizeReturnsCapacityNotLength() { + CyclicBuffer cb = new CyclicBuffer(3); + cb.add("a"); + assertEquals(3, cb.getMaxSize()); + assertSize(cb, 1); + } + + @Test + public void indexedGetReturnsElementsFromOldestToNewest() { + CyclicBuffer cb = new CyclicBuffer(3); + cb.add("a"); + cb.add("b"); + cb.add("c"); + cb.add("d"); + + assertEquals("b", cb.get(0)); + assertEquals("c", cb.get(1)); + assertEquals("d", cb.get(2)); + // indexed access does not remove anything + assertSize(cb, 3); + } + + @Test + public void indexedGetOutsideOfRangeReturnsNull() { + // a full, wrapped buffer: every slot of the backing array holds an element + CyclicBuffer cb = new CyclicBuffer(3); + cb.add("a"); + cb.add("b"); + cb.add("c"); + cb.add("d"); + + assertNull(cb.get(-1)); + assertNull(cb.get(3)); + assertNull(cb.get(4)); + assertEquals("b", cb.get(0)); + assertEquals("d", cb.get(2)); + } + + @Test + public void indexedGetBeyondLengthOfPartiallyFilledBufferReturnsNull() { + CyclicBuffer cb = new CyclicBuffer(3); + cb.add("a"); + cb.add("b"); + + assertNull(cb.get(2)); + assertEquals("b", cb.get(1)); + } + + @Test + public void getOnEmptyBufferReturnsNullAndKeepsBufferUsable() { + CyclicBuffer cb = new CyclicBuffer(2); + assertNull(cb.get()); + assertSize(cb, 0); + + cb.add("a"); + assertEquals("a", cb.get()); + assertNull(cb.get()); + assertSize(cb, 0); + } + @Test public void resizeRejectsNegativeSize() { CyclicBuffer cb = new CyclicBuffer(2); diff --git a/logback-android/src/test/java/ch/qos/logback/core/helpers/ThrowableToStringArrayTest.java b/logback-android/src/test/java/ch/qos/logback/core/helpers/ThrowableToStringArrayTest.java index 5996a92a8..ed9913fe8 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/helpers/ThrowableToStringArrayTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/helpers/ThrowableToStringArrayTest.java @@ -16,6 +16,8 @@ package ch.qos.logback.core.helpers; import static junit.framework.Assert.assertEquals; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertNotNull; import java.io.PrintWriter; import java.io.StringWriter; @@ -81,6 +83,75 @@ public void multiNested() { verify(w); } + private static StackTraceElement frame(String method) { + return new StackTraceElement("com.example.Foo", method, "Foo.java", 1); + } + + private static String at(StackTraceElement ste) { + return "\tat " + ste; + } + + @Test + public void publicNoArgConstructorIsAvailable() throws Exception { + // the class only has static methods, but its implicit public constructor is part of its API + assertNotNull(ThrowableToStringArray.class.getConstructor().newInstance()); + } + + @Test + public void throwableWithoutMessageShowsOnlyItsClassName() { + StackTraceElement a = frame("a"); + Exception e = new Exception(); + e.setStackTrace(new StackTraceElement[] { a }); + + assertArrayEquals(new String[] { "java.lang.Exception", at(a) }, ThrowableToStringArray.convert(e)); + verify(e); + } + + @Test + public void causeWithoutMessageShowsOnlyItsClassName() { + StackTraceElement a = frame("a"); + StackTraceElement b = frame("b"); + IllegalStateException cause = new IllegalStateException(); + cause.setStackTrace(new StackTraceElement[] { b }); + Exception e = new Exception("wrapper", cause); + e.setStackTrace(new StackTraceElement[] { a }); + + assertArrayEquals(new String[] { "java.lang.Exception: wrapper", at(a), + CoreConstants.CAUSED_BY + "java.lang.IllegalStateException", at(b) }, ThrowableToStringArray.convert(e)); + } + + @Test + public void causeWhoseFramesAreAllSharedWithTheParentShowsNoFrame() { + StackTraceElement top = frame("top"); + StackTraceElement mid = frame("mid"); + StackTraceElement bottom = frame("bottom"); + Exception cause = new Exception("cause"); + cause.setStackTrace(new StackTraceElement[] { mid, bottom }); + Exception e = new Exception("wrapper", cause); + e.setStackTrace(new StackTraceElement[] { top, mid, bottom }); + + assertArrayEquals(new String[] { "java.lang.Exception: wrapper", at(top), at(mid), at(bottom), + CoreConstants.CAUSED_BY + "java.lang.Exception: cause", "\t... 2 common frames omitted" }, + ThrowableToStringArray.convert(e)); + verify(e); + } + + @Test + public void causeWithMoreFramesThanTheParentShowsOnlyItsOwnFrames() { + StackTraceElement own0 = frame("own0"); + StackTraceElement own1 = frame("own1"); + StackTraceElement bottom = frame("bottom"); + Exception cause = new Exception("cause"); + cause.setStackTrace(new StackTraceElement[] { own0, own1, bottom }); + Exception e = new Exception("wrapper", cause); + e.setStackTrace(new StackTraceElement[] { bottom }); + + assertArrayEquals(new String[] { "java.lang.Exception: wrapper", at(bottom), + CoreConstants.CAUSED_BY + "java.lang.Exception: cause", at(own0), at(own1), "\t... 1 common frames omitted" }, + ThrowableToStringArray.convert(e)); + verify(e); + } + void someMethod() throws Exception { throw new Exception("someMethod"); } diff --git a/logback-android/src/test/java/ch/qos/logback/core/helpers/TransformTest.java b/logback-android/src/test/java/ch/qos/logback/core/helpers/TransformTest.java new file mode 100644 index 000000000..749e345bf --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/helpers/TransformTest.java @@ -0,0 +1,116 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.helpers; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +public class TransformTest { + + @Test + public void publicNoArgConstructorIsAvailable() throws Exception { + // the class only has static methods, but its implicit public constructor is part of its API + assertNotNull(Transform.class.getConstructor().newInstance()); + } + + @Test + public void escapeTagsReturnsNullForNull() { + assertNull(Transform.escapeTags((String) null)); + } + + @Test + public void escapeTagsReturnsEmptyStringAsIs() { + String empty = new String(""); + assertSame(empty, Transform.escapeTags(empty)); + } + + @Test + public void escapeTagsReturnsSafeStringAsIs() { + String safe = "plain text\twith\ttabs\r\nand line breaks"; + assertSame(safe, Transform.escapeTags(safe)); + } + + @Test + public void escapeTagsReplacesMarkupCharactersWithEntities() { + assertEquals("<a href="x" title='y'>&</a>", + Transform.escapeTags("&")); + } + + @Test + public void escapeTagsKeepsTabsAndLineBreaksNextToEscapedCharacters() { + assertEquals("\t<\n>\r&", Transform.escapeTags("\t<\n>\r&")); + } + + @Test + public void escapeTagsReplacesOtherControlCharactersWithReplacementCharacter() { + assertEquals("a�b�c� d", Transform.escapeTags("a\u0000b\u000bc\u001f d")); + } + + @Test + public void escapeTagsOfStringBufferEscapesTheBufferInPlace() { + StringBuffer buf = new StringBuffer("x<\u0001y"); + + String result = Transform.escapeTags(buf); + + assertEquals("x<�y", result); + assertEquals("x<�y", buf.toString()); + } + + @Test + public void escapeTagsOfStringBufferWithoutSpecialCharactersLeavesItUnchanged() { + StringBuffer buf = new StringBuffer("a b\tc"); + assertEquals("a b\tc", Transform.escapeTags(buf)); + } + + @Test + public void appendEscapingCDATAIgnoresNull() { + StringBuilder output = new StringBuilder("pre"); + Transform.appendEscapingCDATA(output, null); + assertEquals("pre", output.toString()); + } + + @Test + public void appendEscapingCDATAAppendsStringWithoutCDATAEndAsIs() { + StringBuilder output = new StringBuilder("pre"); + Transform.appendEscapingCDATA(output, "a]]b>c<"); + assertEquals("prea]]b>c<", output.toString()); + } + + @Test + public void appendEscapingCDATASplitsTheSectionAroundAnEmbeddedCDATAEnd() { + StringBuilder output = new StringBuilder(); + Transform.appendEscapingCDATA(output, "a]]>b"); + assertEquals("a]]>]]>x]]>y"); + assertEquals("]]>]]>]]>"); + assertEquals("a]]>]]> "); + + chain.recursive(chain, sb); + + assertEquals("> Node{type=LITERAL, payload='a'} --> Node{type=LITERAL, payload='b'} --> null ", sb.toString()); + } + + @Test + public void recursiveOnNullChainAppendsOnlyTerminator() { + StringBuilder sb = new StringBuilder(); + literal("a").recursive(null, sb); + assertEquals("null ", sb.toString()); + } + + @Test + public void nodeEqualsItselfEvenWhenPayloadDoesNot() { + Node node = new Node(Node.Type.LITERAL, NEVER_EQUAL); + assertTrue(node.equals(node)); + assertFalse(node.equals(new Node(Node.Type.LITERAL, NEVER_EQUAL))); + } + + @Test + public void nodeIsNotEqualToNullOrToAnotherClass() { + Node node = literal("a"); + assertFalse(node.equals(null)); + assertFalse(node.equals("a")); + Node subclassInstance = new Node(Node.Type.LITERAL, "a") { + }; + assertFalse(node.equals(subclassInstance)); + } + + @Test + public void nodesWithDifferentTypesAreNotEqual() { + assertNotEquals(new Node(Node.Type.LITERAL, "a"), new Node(Node.Type.VARIABLE, "a")); + } + + @Test + public void equalsComparesPayload() { + assertEquals(new Node(Node.Type.LITERAL, null), new Node(Node.Type.LITERAL, null)); + assertEquals(literal("a"), literal("a")); + assertNotEquals(literal("a"), literal("b")); + assertNotEquals(literal("a"), new Node(Node.Type.LITERAL, null)); + assertNotEquals(new Node(Node.Type.LITERAL, null), literal("a")); + } + + @Test + public void equalsComparesDefaultPart() { + Node withoutDefault = new Node(Node.Type.VARIABLE, literal("k")); + Node withDefaultX = new Node(Node.Type.VARIABLE, literal("k"), literal("x")); + + assertEquals(withDefaultX, new Node(Node.Type.VARIABLE, literal("k"), literal("x"))); + assertNotEquals(withDefaultX, new Node(Node.Type.VARIABLE, literal("k"), literal("y"))); + assertNotEquals(withDefaultX, withoutDefault); + assertNotEquals(withoutDefault, withDefaultX); + } + + @Test + public void equalsComparesNext() { + Node a1 = literal("a"); + a1.next = literal("b"); + Node a2 = literal("a"); + a2.next = literal("b"); + Node a3 = literal("a"); + a3.next = literal("c"); + Node aAlone = literal("a"); + + assertEquals(a1, a2); + assertNotEquals(a1, a3); + assertNotEquals(a1, aAlone); + assertNotEquals(aAlone, a1); + } + + @Test + public void hashCodeOfNodeWithoutFieldsIsZero() { + assertEquals(0, new Node(null, null).hashCode()); + } + + @Test + public void hashCodeCombinesEveryField() { + int typeHash = Node.Type.LITERAL.hashCode(); + assertEquals(31 * 31 * 31 * typeHash, new Node(Node.Type.LITERAL, null).hashCode()); + assertEquals(31 * 31 * "p".hashCode(), new Node(null, "p").hashCode()); + assertEquals(31 * "d".hashCode(), new Node(null, null, "d").hashCode()); + + Node withNext = new Node(null, null); + withNext.next = new Node(null, "n"); + assertEquals(31 * 31 * "n".hashCode(), withNext.hashCode()); + } + + @Test + public void equalNodesHaveEqualHashCodes() { + Node n1 = new Node(Node.Type.VARIABLE, literal("k"), literal("x")); + n1.next = literal("tail"); + Node n2 = new Node(Node.Type.VARIABLE, literal("k"), literal("x")); + n2.next = literal("tail"); + + assertEquals(n1, n2); + assertEquals(n1.hashCode(), n2.hashCode()); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/subst/NodeToStringTransformerTest.java b/logback-android/src/test/java/ch/qos/logback/core/subst/NodeToStringTransformerTest.java index 45be2f8c0..3833cb8b8 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/subst/NodeToStringTransformerTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/subst/NodeToStringTransformerTest.java @@ -16,22 +16,38 @@ package ch.qos.logback.core.subst; import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.CoreConstants; import ch.qos.logback.core.spi.ScanException; +import ch.qos.logback.core.util.OptionHelper; +import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.lang.reflect.Method; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; /** * @author Ceki Gücü */ public class NodeToStringTransformerTest { + static final String SYS_PROP_KEY = "NodeToStringTransformerTest.sysProp"; + static final String UNDEFINED_KEY = "NodeToStringTransformerTest.undefined"; + ContextBase propertyContainer0 = new ContextBase(); + ContextBase propertyContainer1 = new ContextBase(); + String savedSysProp; @Before public void setUp() { + savedSysProp = System.getProperty(SYS_PROP_KEY); propertyContainer0.putProperty("k0", "v0"); propertyContainer0.putProperty("zero", "0"); propertyContainer0.putProperty("v0.jdbc.url", "http://.."); @@ -39,6 +55,15 @@ public void setUp() { } + @After + public void tearDown() { + if (savedSysProp == null) { + System.clearProperty(SYS_PROP_KEY); + } else { + System.setProperty(SYS_PROP_KEY, savedSysProp); + } + } + private Node makeNode(String input) throws ScanException { Tokenizer tokenizer = new Tokenizer(input); Parser parser = new Parser(tokenizer.tokenize()); @@ -141,4 +166,119 @@ public void LOGBACK_1101() throws ScanException { NodeToStringTransformer nodeToStringTransformer = new NodeToStringTransformer(node, propertyContainer0); assertEquals("a: {y}", nodeToStringTransformer.transform()); } + + @Test + public void valueFromSecondPropertyContainerIsUsedWhenFirstLacksKey() throws ScanException { + propertyContainer1.putProperty("k1", "v1"); + assertEquals("a-v1", NodeToStringTransformer.substituteVariable("a-${k1}", propertyContainer0, propertyContainer1)); + } + + @Test + public void firstPropertyContainerTakesPrecedenceOverSecond() throws ScanException { + propertyContainer1.putProperty("k0", "fromSecond"); + assertEquals("v0", NodeToStringTransformer.substituteVariable("${k0}", propertyContainer0, propertyContainer1)); + } + + @Test + public void secondPropertyContainerTakesPrecedenceOverSystemProperties() throws ScanException { + System.setProperty(SYS_PROP_KEY, "fromSystem"); + propertyContainer1.putProperty(SYS_PROP_KEY, "fromSecond"); + assertEquals("fromSecond", NodeToStringTransformer.substituteVariable("${" + SYS_PROP_KEY + "}", propertyContainer0, propertyContainer1)); + } + + @Test + public void systemPropertyIsUsedWhenNeitherContainerHasKey() throws ScanException { + System.setProperty(SYS_PROP_KEY, "fromSystem"); + propertyContainer1.putProperty("k1", "v1"); + assertEquals("fromSystem", NodeToStringTransformer.substituteVariable("${" + SYS_PROP_KEY + "}", propertyContainer0, propertyContainer1)); + } + + @Test + public void environmentVariableIsUsedWhenNoContainerOrSystemPropertyHasKey() throws ScanException { + try (MockedStatic optionHelper = Mockito.mockStatic(OptionHelper.class, Mockito.CALLS_REAL_METHODS)) { + optionHelper.when(() -> OptionHelper.getEnv(SYS_PROP_KEY)).thenReturn("fromEnv"); + assertEquals("fromEnv", NodeToStringTransformer.substituteVariable("${" + SYS_PROP_KEY + "}", propertyContainer0, propertyContainer1)); + } + } + + @Test + public void systemPropertyTakesPrecedenceOverEnvironmentVariable() throws ScanException { + System.setProperty(SYS_PROP_KEY, "fromSystem"); + try (MockedStatic optionHelper = Mockito.mockStatic(OptionHelper.class, Mockito.CALLS_REAL_METHODS)) { + optionHelper.when(() -> OptionHelper.getEnv(SYS_PROP_KEY)).thenReturn("fromEnv"); + assertEquals("fromSystem", NodeToStringTransformer.substituteVariable("${" + SYS_PROP_KEY + "}", propertyContainer0, propertyContainer1)); + } + } + + @Test + public void keyMissingEverywhereIsReportedAsUndefined() throws ScanException { + propertyContainer1.putProperty("k1", "v1"); + assertEquals(UNDEFINED_KEY + CoreConstants.UNDEFINED_PROPERTY_SUFFIX, + NodeToStringTransformer.substituteVariable("${" + UNDEFINED_KEY + "}", propertyContainer0, propertyContainer1)); + } + + @Test + public void circularReferenceIsReported() { + propertyContainer0.putProperty("a", "${b}"); + propertyContainer0.putProperty("b", "${a}"); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> NodeToStringTransformer.substituteVariable("${a}", propertyContainer0, null)); + assertEquals("Circular variable reference detected while parsing input [${a} --> ${b} --> ${a}]", e.getMessage()); + } + + @Test + public void selfReferenceWithSameDefaultValueIsReportedAsCircular() { + propertyContainer0.putProperty("self", "${self:-x}"); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> NodeToStringTransformer.substituteVariable("${self:-x}", propertyContainer0, null)); + assertEquals("Circular variable reference detected while parsing input [${self} --> ${self}]", e.getMessage()); + } + + @Test + public void selfReferenceWithOtherDefaultValueIsNotTheSameVariable() { + // "${self:-y}" differs from "${self:-x}" by its default value only; the cycle + // is detected one level deeper, when "${self:-y}" is met a second time. + propertyContainer0.putProperty("self", "${self:-y}"); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> NodeToStringTransformer.substituteVariable("${self:-x}", propertyContainer0, null)); + assertEquals("Circular variable reference detected while parsing input [${self} --> ${self} --> ${self}]", e.getMessage()); + } + + private boolean equalNodes(Node candidate, Node visited) throws Exception { + Method m = NodeToStringTransformer.class.getDeclaredMethod("equalNodes", Node.class, Node.class); + m.setAccessible(true); + return (Boolean) m.invoke(new NodeToStringTransformer(null, propertyContainer0), candidate, visited); + } + + private static Node literal(String s) { + return new Node(Node.Type.LITERAL, s); + } + + @Test + public void cycleCheckTreatsNodesOfDifferentTypesAsDistinct() throws Exception { + assertFalse(equalNodes(new Node(Node.Type.LITERAL, literal("k")), new Node(Node.Type.VARIABLE, literal("k")))); + } + + @Test + public void cycleCheckComparesPayloadAndDefaultPart() throws Exception { + Node visited = new Node(Node.Type.VARIABLE, literal("k"), literal("d")); + assertTrue(equalNodes(new Node(Node.Type.VARIABLE, literal("k"), literal("d")), visited)); + assertFalse(equalNodes(new Node(Node.Type.VARIABLE, literal("other"), literal("d")), visited)); + assertFalse(equalNodes(new Node(Node.Type.VARIABLE, literal("k"), literal("other")), visited)); + } + + @Test + public void cycleCheckIgnoresFieldsTheCandidateNodeLacks() throws Exception { + Node visited = new Node(Node.Type.VARIABLE, literal("k"), literal("d")); + assertTrue(equalNodes(new Node(null, literal("k"), literal("d")), visited)); + assertTrue(equalNodes(new Node(Node.Type.VARIABLE, null, literal("d")), visited)); + assertTrue(equalNodes(new Node(Node.Type.VARIABLE, literal("k")), visited)); + } + + @Test + public void cycleCheckIgnoresNextNode() throws Exception { + Node candidate = new Node(Node.Type.VARIABLE, literal("k")); + candidate.next = literal("tail"); + assertTrue(equalNodes(candidate, new Node(Node.Type.VARIABLE, literal("k")))); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/subst/ParserTest.java b/logback-android/src/test/java/ch/qos/logback/core/subst/ParserTest.java index 7cf6c1068..2adc627ed 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/subst/ParserTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/subst/ParserTest.java @@ -16,6 +16,8 @@ package ch.qos.logback.core.subst; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; import java.util.ArrayList; @@ -190,6 +192,34 @@ public void emptyTokenListDoesNotThrowNullPointerException() throws ScanExceptio parser.parse(); } + @Test + public void nullTokenListParsesToNull() throws ScanException { + Parser parser = new Parser(null); + assertNull(parser.parse()); + } + + @Test + public void emptyTokenListParsesToNull() throws ScanException { + Parser parser = new Parser(new ArrayList()); + assertNull(parser.parse()); + } + + @Test + public void secondDefaultSeparatorInVariableIsRejected() throws ScanException { + Tokenizer tokenizer = new Tokenizer("${a:-b:-c}"); + Parser parser = new Parser(tokenizer.tokenize()); + ScanException e = assertThrows(ScanException.class, parser::parse); + assertEquals("Expecting }", e.getMessage()); + } + + @Test + public void unclosedVariableIsRejected() throws ScanException { + Tokenizer tokenizer = new Tokenizer("${a"); + Parser parser = new Parser(tokenizer.tokenize()); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, parser::parse); + assertEquals("All tokens consumed but was expecting \"}\"", e.getMessage()); + } + private void dump(Node node) { while (node != null) { System.out.println(node.toString()); diff --git a/logback-android/src/test/java/ch/qos/logback/core/subst/TokenTest.java b/logback-android/src/test/java/ch/qos/logback/core/subst/TokenTest.java new file mode 100644 index 000000000..4fbf49727 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/subst/TokenTest.java @@ -0,0 +1,84 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.subst; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +public class TokenTest { + + @Test + public void tokenEqualsItself() { + Token token = new Token(Token.Type.LITERAL, "a"); + assertTrue(token.equals(token)); + } + + @Test + public void tokenIsNotEqualToNullOrToAnotherClass() { + Token token = new Token(Token.Type.LITERAL, "a"); + assertFalse(token.equals(null)); + assertFalse(token.equals("a")); + Token subclassInstance = new Token(Token.Type.LITERAL, "a") { + }; + assertFalse(token.equals(subclassInstance)); + } + + @Test + public void tokensWithDifferentTypesAreNotEqual() { + assertNotEquals(new Token(Token.Type.LITERAL, "a"), new Token(Token.Type.START, "a")); + assertNotEquals(Token.CURLY_LEFT_TOKEN, Token.CURLY_RIGHT_TOKEN); + } + + @Test + public void equalsComparesPayload() { + assertEquals(new Token(Token.Type.START, null), Token.START_TOKEN); + assertEquals(new Token(Token.Type.LITERAL, "a"), new Token(Token.Type.LITERAL, "a")); + assertNotEquals(new Token(Token.Type.LITERAL, "a"), new Token(Token.Type.LITERAL, "b")); + assertNotEquals(new Token(Token.Type.LITERAL, "a"), new Token(Token.Type.LITERAL, null)); + assertNotEquals(new Token(Token.Type.LITERAL, null), new Token(Token.Type.LITERAL, "a")); + } + + @Test + public void hashCodeOfTokenWithoutFieldsIsZero() { + assertEquals(0, new Token(null, null).hashCode()); + } + + @Test + public void hashCodeCombinesTypeAndPayload() { + assertEquals(31 * Token.Type.DEFAULT.hashCode(), Token.DEFAULT_SEP_TOKEN.hashCode()); + assertEquals("p".hashCode(), new Token(null, "p").hashCode()); + assertEquals(31 * Token.Type.LITERAL.hashCode() + "p".hashCode(), new Token(Token.Type.LITERAL, "p").hashCode()); + } + + @Test + public void equalTokensHaveEqualHashCodes() { + assertEquals(new Token(Token.Type.LITERAL, "a").hashCode(), new Token(Token.Type.LITERAL, "a").hashCode()); + } + + @Test + public void toStringShowsPayloadWhenPresent() { + assertEquals("Token{type=LITERAL, payload='abc'}", new Token(Token.Type.LITERAL, "abc").toString()); + } + + @Test + public void toStringOmitsMissingPayload() { + assertEquals("Token{type=START}", Token.START_TOKEN.toString()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/subst/TokenizerTest.java b/logback-android/src/test/java/ch/qos/logback/core/subst/TokenizerTest.java index 472ac7aff..dbd16631c 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/subst/TokenizerTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/subst/TokenizerTest.java @@ -190,4 +190,17 @@ public void LOGBACK_1101() throws ScanException { witnessList.add(Token.CURLY_RIGHT_TOKEN); assertEquals(witnessList, tokenList); } + + @Test + public void dollarNotFollowedByCurlyLeftIsLiteral() throws ScanException { + String input = "a$b{c}"; + Tokenizer tokenizer = new Tokenizer(input); + List tokenList = tokenizer.tokenize(); + witnessList.add(new Token(Token.Type.LITERAL, "a")); + witnessList.add(new Token(Token.Type.LITERAL, "$b")); + witnessList.add(Token.CURLY_LEFT_TOKEN); + witnessList.add(new Token(Token.Type.LITERAL, "c")); + witnessList.add(Token.CURLY_RIGHT_TOKEN); + assertEquals(witnessList, tokenList); + } } From c8df5f3737563eaa7453610d6e5792f58d8bf3f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:15:07 +0000 Subject: [PATCH 22/79] test: spell the replacement character as a \uFFFD escape in TransformTest The expected strings held a literal U+FFFD, which reads like an encoding error and depends on the source being decoded as UTF-8. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../java/ch/qos/logback/core/helpers/TransformTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/logback-android/src/test/java/ch/qos/logback/core/helpers/TransformTest.java b/logback-android/src/test/java/ch/qos/logback/core/helpers/TransformTest.java index 749e345bf..c6062f2cc 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/helpers/TransformTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/helpers/TransformTest.java @@ -60,7 +60,7 @@ public void escapeTagsKeepsTabsAndLineBreaksNextToEscapedCharacters() { @Test public void escapeTagsReplacesOtherControlCharactersWithReplacementCharacter() { - assertEquals("a�b�c� d", Transform.escapeTags("a\u0000b\u000bc\u001f d")); + assertEquals("a\uFFFDb\uFFFDc\uFFFD d", Transform.escapeTags("a\u0000b\u000bc\u001f d")); } @Test @@ -69,8 +69,8 @@ public void escapeTagsOfStringBufferEscapesTheBufferInPlace() { String result = Transform.escapeTags(buf); - assertEquals("x<�y", result); - assertEquals("x<�y", buf.toString()); + assertEquals("x<\uFFFDy", result); + assertEquals("x<\uFFFDy", buf.toString()); } @Test From be7afeaea63f409595a0df554a204c589828ff27 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:37:44 +0000 Subject: [PATCH 23/79] test: cover classic Logger overloads, turbo replies and child creation Add LoggerApiTest, a plain-JVM complement to the Robolectric-run LoggerTest, so every line and branch of ch.qos.logback.classic.Logger is covered. The tests pin: - each trace/debug/info/warn/error overload (plain, Marker and List variants) appends an event with its level, message, arguments, throwable and markers; - the ACCEPT/NEUTRAL/DENY handling of turbo filter replies for 0-, 1- and 2-argument calls, and that isXxxEnabled(Marker) asks the filters about that marker at the matching level; - isXxxEnabled/isEnabledFor reject a reply that is none of ACCEPT/NEUTRAL/DENY with "Unknown FilterReply value: null"; - log(List, fqcn, ...) and log(slf4j LoggingEvent) map the level, carry markers/arguments/throwable and use the given caller boundary; - appender queries on loggers with and without appenders, the effective level int, and createChildByLastNamePart/createChildByName naming and separator checks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../ch/qos/logback/classic/LoggerApiTest.java | 584 ++++++++++++++++++ 1 file changed, 584 insertions(+) create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/LoggerApiTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/classic/LoggerApiTest.java b/logback-android/src/test/java/ch/qos/logback/classic/LoggerApiTest.java new file mode 100644 index 000000000..6215226b4 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/LoggerApiTest.java @@ -0,0 +1,584 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.function.ThrowingRunnable; +import org.slf4j.Marker; +import org.slf4j.event.DefaultLoggingEvent; +import org.slf4j.helpers.BasicMarkerFactory; +import org.slf4j.spi.LocationAwareLogger; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.classic.spi.ThrowableProxy; +import ch.qos.logback.classic.turbo.TurboFilter; +import ch.qos.logback.core.read.ListAppender; +import ch.qos.logback.core.spi.FilterReply; + +/** + * Plain-JVM tests of {@link Logger}: every printing overload, the + * {@code isXxxEnabled} checks and how they follow turbo filter replies, the + * {@code log} entry points, appender attachment and child creation. + * {@link LoggerTest} covers the basics under Robolectric. + */ +public class LoggerApiTest { + + static final BasicMarkerFactory MARKER_FACTORY = new BasicMarkerFactory(); + static final Marker BLUE = MARKER_FACTORY.getDetachedMarker("BLUE"); + static final Marker RED = MARKER_FACTORY.getDetachedMarker("RED"); + static final List MARKERS = Arrays.asList(BLUE, RED); + + static final Object A1 = "a1"; + static final Object A2 = "a2"; + static final Object A3 = "a3"; + static final Throwable EX = new Exception("boom"); + + /** + * The five argument shapes of each printing method, in the order in which + * {@code log*Overloads} call them: message only, one argument, two + * arguments, varargs and throwable. + */ + static final String[] SHAPE_MESSAGES = { "m0", "m1 {}", "m2 {} {}", "m3 {} {} {}", "mt" }; + static final Object[][] SHAPE_ARGS = { null, { A1 }, { A1, A2 }, { A1, A2, A3 }, null }; + static final int THROWABLE_SHAPE = 4; + + LoggerContext lc = new LoggerContext(); + Logger root = lc.getLogger(Logger.ROOT_LOGGER_NAME); + Logger logger = lc.getLogger(LoggerApiTest.class); + CallerDataCapturingAppender appender = new CallerDataCapturingAppender(); + + @Before + public void setUp() { + appender.setContext(lc); + appender.setName("capture"); + appender.start(); + root.addAppender(appender); + } + + @After + public void tearDown() { + lc.stop(); + } + + // ---------------------------------------------------------------- appenders + + @Test + public void loggerWithoutAppendersReportsNoneAttached() { + Logger bare = lc.getLogger("bare"); + ListAppender other = new ListAppender(); + other.setName("other"); + + assertFalse(bare.detachAppender("other")); + assertFalse(bare.detachAppender(other)); + assertFalse(bare.isAttached(other)); + assertNull(bare.getAppender("other")); + assertFalse(bare.iteratorForAppenders().hasNext()); + } + + @Test + public void appenderQueriesReflectAttachAndDetachByName() { + Logger owner = lc.getLogger("owner"); + ListAppender other = new ListAppender(); + other.setName("other"); + owner.addAppender(other); + + assertTrue(owner.isAttached(other)); + assertFalse(owner.isAttached(appender)); + assertSame(other, owner.getAppender("other")); + assertFalse(owner.detachAppender("unknown")); + assertTrue(owner.detachAppender("other")); + + assertFalse(owner.isAttached(other)); + assertNull(owner.getAppender("other")); + assertFalse(owner.detachAppender("other")); + } + + @Test + public void appenderQueriesReflectAttachAndDetachByInstance() { + Logger owner = lc.getLogger("owner"); + ListAppender other = new ListAppender(); + owner.addAppender(other); + + assertFalse(owner.detachAppender(appender)); + assertTrue(owner.detachAppender(other)); + + assertFalse(owner.isAttached(other)); + assertFalse(owner.iteratorForAppenders().hasNext()); + assertFalse(owner.detachAppender(other)); + } + + // ------------------------------------------------------------------ levels + + @Test + public void effectiveLevelIntFollowsTheInheritedAndTheAssignedLevel() { + root.setLevel(Level.WARN); + assertEquals(Level.WARN_INT, logger.getEffectiveLevelInt()); + + logger.setLevel(Level.TRACE); + assertEquals(Level.TRACE_INT, logger.getEffectiveLevelInt()); + } + + // ---------------------------------------------------------- child creation + + @Test + public void childCreatedByLastNamePartOfRootIsNamedByThatPartAlone() { + root.setLevel(Level.WARN); + + Logger child = root.createChildByLastNamePart("x"); + + assertEquals("x", child.getName()); + assertSame(child, root.getChildByName("x")); + assertEquals(Level.WARN, child.getEffectiveLevel()); + assertNull(child.getLevel()); + } + + @Test + public void childrenCreatedByLastNamePartOfNonRootLoggerArePrefixedWithItsName() { + Logger parent = lc.getLogger("p"); + parent.setLevel(Level.ERROR); + + Logger first = parent.createChildByLastNamePart("c1"); + Logger second = parent.createChildByLastNamePart("c2"); + + assertEquals("p.c1", first.getName()); + assertEquals("p.c2", second.getName()); + assertSame(first, parent.getChildByName("p.c1")); + assertSame(second, parent.getChildByName("p.c2")); + assertEquals(Level.ERROR, first.getEffectiveLevel()); + assertEquals(Level.ERROR, second.getEffectiveLevel()); + + // the children are linked to the parent: level changes propagate + parent.setLevel(Level.INFO); + assertEquals(Level.INFO, first.getEffectiveLevel()); + assertEquals(Level.INFO, second.getEffectiveLevel()); + } + + @Test + public void createChildByLastNamePartRejectsANameWithASeparator() { + Logger parent = lc.getLogger("p"); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> parent.createChildByLastNamePart("c.d")); + + assertEquals("Child name [c.d passed as parameter, may not include [.]", e.getMessage()); + assertNull(parent.getChildByName("p.c.d")); + } + + @Test + public void createChildByNameRejectsASeparatorAfterTheParentName() { + Logger parent = lc.getLogger("p"); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> parent.createChildByName("p.c.d")); + + assertEquals("For logger [p] child name [p.c.d passed as parameter, may not include '.' after index2", + e.getMessage()); + assertNull(parent.getChildByName("p.c.d")); + } + + // --------------------------------------------------------- print overloads + + @Test + public void traceOverloadsAppendEventsWithTheirMarkersArgumentsAndThrowable() { + logger.setLevel(Level.TRACE); + + logger.trace("m0"); + logger.trace("m1 {}", A1); + logger.trace("m2 {} {}", A1, A2); + logger.trace("m3 {} {} {}", A1, A2, A3); + logger.trace("mt", EX); + logger.trace(BLUE, "m0"); + logger.trace(BLUE, "m1 {}", A1); + logger.trace(BLUE, "m2 {} {}", A1, A2); + logger.trace(BLUE, "m3 {} {} {}", A1, A2, A3); + logger.trace(BLUE, "mt", EX); + logger.trace(MARKERS, "m0"); + logger.trace(MARKERS, "m1 {}", A1); + logger.trace(MARKERS, "m2 {} {}", A1, A2); + logger.trace(MARKERS, "m3 {} {} {}", A1, A2, A3); + logger.trace(MARKERS, "mt", EX); + + assertOverloadEvents(Level.TRACE); + } + + @Test + public void debugOverloadsAppendEventsWithTheirMarkersArgumentsAndThrowable() { + logger.setLevel(Level.DEBUG); + + logger.debug("m0"); + logger.debug("m1 {}", A1); + logger.debug("m2 {} {}", A1, A2); + logger.debug("m3 {} {} {}", A1, A2, A3); + logger.debug("mt", EX); + logger.debug(BLUE, "m0"); + logger.debug(BLUE, "m1 {}", A1); + logger.debug(BLUE, "m2 {} {}", A1, A2); + logger.debug(BLUE, "m3 {} {} {}", A1, A2, A3); + logger.debug(BLUE, "mt", EX); + logger.debug(MARKERS, "m0"); + logger.debug(MARKERS, "m1 {}", A1); + logger.debug(MARKERS, "m2 {} {}", A1, A2); + logger.debug(MARKERS, "m3 {} {} {}", A1, A2, A3); + logger.debug(MARKERS, "mt", EX); + + assertOverloadEvents(Level.DEBUG); + } + + @Test + public void infoOverloadsAppendEventsWithTheirMarkersArgumentsAndThrowable() { + logger.setLevel(Level.INFO); + + logger.info("m0"); + logger.info("m1 {}", A1); + logger.info("m2 {} {}", A1, A2); + logger.info("m3 {} {} {}", A1, A2, A3); + logger.info("mt", EX); + logger.info(BLUE, "m0"); + logger.info(BLUE, "m1 {}", A1); + logger.info(BLUE, "m2 {} {}", A1, A2); + logger.info(BLUE, "m3 {} {} {}", A1, A2, A3); + logger.info(BLUE, "mt", EX); + logger.info(MARKERS, "m0"); + logger.info(MARKERS, "m1 {}", A1); + logger.info(MARKERS, "m2 {} {}", A1, A2); + logger.info(MARKERS, "m3 {} {} {}", A1, A2, A3); + logger.info(MARKERS, "mt", EX); + + assertOverloadEvents(Level.INFO); + } + + @Test + public void warnOverloadsAppendEventsWithTheirMarkersArgumentsAndThrowable() { + logger.setLevel(Level.WARN); + + logger.warn("m0"); + logger.warn("m1 {}", A1); + logger.warn("m2 {} {}", A1, A2); + logger.warn("m3 {} {} {}", A1, A2, A3); + logger.warn("mt", EX); + logger.warn(BLUE, "m0"); + logger.warn(BLUE, "m1 {}", A1); + logger.warn(BLUE, "m2 {} {}", A1, A2); + logger.warn(BLUE, "m3 {} {} {}", A1, A2, A3); + logger.warn(BLUE, "mt", EX); + logger.warn(MARKERS, "m0"); + logger.warn(MARKERS, "m1 {}", A1); + logger.warn(MARKERS, "m2 {} {}", A1, A2); + logger.warn(MARKERS, "m3 {} {} {}", A1, A2, A3); + logger.warn(MARKERS, "mt", EX); + + assertOverloadEvents(Level.WARN); + } + + @Test + public void errorOverloadsAppendEventsWithTheirMarkersArgumentsAndThrowable() { + logger.setLevel(Level.ERROR); + + logger.error("m0"); + logger.error("m1 {}", A1); + logger.error("m2 {} {}", A1, A2); + logger.error("m3 {} {} {}", A1, A2, A3); + logger.error("mt", EX); + logger.error(BLUE, "m0"); + logger.error(BLUE, "m1 {}", A1); + logger.error(BLUE, "m2 {} {}", A1, A2); + logger.error(BLUE, "m3 {} {} {}", A1, A2, A3); + logger.error(BLUE, "mt", EX); + logger.error(MARKERS, "m0"); + logger.error(MARKERS, "m1 {}", A1); + logger.error(MARKERS, "m2 {} {}", A1, A2); + logger.error(MARKERS, "m3 {} {} {}", A1, A2, A3); + logger.error(MARKERS, "mt", EX); + + assertOverloadEvents(Level.ERROR); + } + + // ------------------------------------------- turbo filters on print calls + + @Test + public void neutralReplyDropsOneAndTwoArgumentCallsBelowTheEffectiveLevel() { + RecordingTurboFilter filter = addTurboFilter(FilterReply.NEUTRAL); + logger.setLevel(Level.INFO); + + logger.debug("d1 {}", A1); + logger.debug("d2 {} {}", A1, A2); + assertEquals(0, appender.list.size()); + assertEquals(2, filter.calls); + + logger.info("i1 {}", A1); + logger.info("i2 {} {}", A1, A2); + assertEquals(2, appender.list.size()); + assertEquals("i1 {}", appender.list.get(0).getMessage()); + assertEquals("i2 {} {}", appender.list.get(1).getMessage()); + } + + @Test + public void acceptReplyAppendsCallsBelowTheEffectiveLevel() { + RecordingTurboFilter filter = addTurboFilter(FilterReply.ACCEPT); + logger.setLevel(Level.OFF); + + logger.debug("d0"); + filter.assertAsked(Level.DEBUG, null, "d0", null); + logger.debug("d1 {}", A1); + filter.assertAsked(Level.DEBUG, null, "d1 {}", new Object[] { A1 }); + logger.debug(MARKERS, "d2 {} {}", A1, A2); + filter.assertAsked(Level.DEBUG, MARKERS, "d2 {} {}", new Object[] { A1, A2 }); + + assertEquals(3, appender.list.size()); + assertEquals("d0", appender.list.get(0).getFormattedMessage()); + assertEquals("d1 a1", appender.list.get(1).getFormattedMessage()); + assertEquals("d2 a1 a2", appender.list.get(2).getFormattedMessage()); + assertEquals(Level.DEBUG, appender.list.get(2).getLevel()); + assertSame(MARKERS, appender.list.get(2).getMarkers()); + } + + @Test + public void denyReplyDropsCallsAboveTheEffectiveLevel() { + RecordingTurboFilter filter = addTurboFilter(FilterReply.DENY); + logger.setLevel(Level.ALL); + + logger.error("e0"); + logger.error("e1 {}", A1); + logger.error("e2 {} {}", A1, A2); + logger.error("e3 {} {} {}", A1, A2, A3); + + assertEquals(4, filter.calls); + assertEquals(0, appender.list.size()); + } + + // ------------------------------------------------------- isXxxEnabled + + @Test + public void markerEnabledChecksAskTurboFiltersAboutThatMarkerAtTheirLevel() { + RecordingTurboFilter filter = addTurboFilter(FilterReply.NEUTRAL); + logger.setLevel(Level.INFO); + List blueOnly = Collections.singletonList(BLUE); + + assertFalse(logger.isTraceEnabled(BLUE)); + filter.assertAsked(Level.TRACE, blueOnly, null, null); + assertFalse(logger.isDebugEnabled(BLUE)); + filter.assertAsked(Level.DEBUG, blueOnly, null, null); + assertTrue(logger.isInfoEnabled(BLUE)); + filter.assertAsked(Level.INFO, blueOnly, null, null); + assertTrue(logger.isWarnEnabled(BLUE)); + filter.assertAsked(Level.WARN, blueOnly, null, null); + assertTrue(logger.isErrorEnabled(BLUE)); + filter.assertAsked(Level.ERROR, blueOnly, null, null); + } + + @Test + public void acceptReplyEnablesEveryLevelOfALoggerThatIsOff() { + addTurboFilter(FilterReply.ACCEPT); + logger.setLevel(Level.OFF); + + assertTrue(logger.isTraceEnabled()); + assertTrue(logger.isDebugEnabled()); + assertTrue(logger.isInfoEnabled()); + assertTrue(logger.isWarnEnabled()); + assertTrue(logger.isErrorEnabled()); + assertTrue(logger.isEnabledFor(Level.TRACE)); + } + + @Test + public void denyReplyDisablesEveryLevelOfALoggerThatAcceptsAll() { + addTurboFilter(FilterReply.DENY); + logger.setLevel(Level.ALL); + + assertFalse(logger.isTraceEnabled()); + assertFalse(logger.isDebugEnabled()); + assertFalse(logger.isInfoEnabled()); + assertFalse(logger.isWarnEnabled()); + assertFalse(logger.isErrorEnabled()); + assertFalse(logger.isEnabledFor(Level.ERROR)); + } + + @Test + public void enabledChecksRejectAReplyOtherThanAcceptNeutralOrDeny() { + addTurboFilter(null); + + assertUnknownReply(() -> logger.isTraceEnabled()); + assertUnknownReply(() -> logger.isDebugEnabled()); + assertUnknownReply(() -> logger.isInfoEnabled()); + assertUnknownReply(() -> logger.isWarnEnabled()); + assertUnknownReply(() -> logger.isErrorEnabled()); + assertUnknownReply(() -> logger.isEnabledFor(Level.INFO)); + } + + // -------------------------------------------------------------- log(...) + + @Test + public void logWithMarkerListAppendsAtTheGivenLevelAndReportsTheCallerOfTheGivenClass() { + logger.setLevel(Level.INFO); + + Facade.log(logger, MARKERS, LocationAwareLogger.DEBUG_INT, "dropped"); + assertEquals(0, appender.list.size()); + + Facade.log(logger, MARKERS, LocationAwareLogger.WARN_INT, "w {}", A1); + + assertEquals(1, appender.list.size()); + ILoggingEvent e = appender.list.get(0); + assertEquals(Level.WARN, e.getLevel()); + assertSame(MARKERS, e.getMarkers()); + assertEquals("w {}", e.getMessage()); + assertArrayEquals(new Object[] { A1 }, e.getArgumentArray()); + assertSame(EX, throwableOf(e)); + // the frames of the class named as fqcn are skipped: the caller is this test + StackTraceElement caller = e.getCallerData()[0]; + assertEquals(LoggerApiTest.class.getName(), caller.getClassName()); + assertEquals("logWithMarkerListAppendsAtTheGivenLevelAndReportsTheCallerOfTheGivenClass", + caller.getMethodName()); + } + + @Test + public void logSlf4jEventReplaysItsLevelMarkersMessageArgumentsAndThrowable() { + logger.setLevel(Level.INFO); + + DefaultLoggingEvent dropped = new DefaultLoggingEvent(org.slf4j.event.Level.DEBUG, logger); + dropped.setMessage("dropped"); + logger.log(dropped); + assertEquals(0, appender.list.size()); + + DefaultLoggingEvent replayed = new DefaultLoggingEvent(org.slf4j.event.Level.WARN, logger); + replayed.addMarker(BLUE); + replayed.setMessage("w {} {}"); + replayed.addArguments(A1, A2); + replayed.setThrowable(EX); + logger.log(replayed); + + assertEquals(1, appender.list.size()); + ILoggingEvent e = appender.list.get(0); + assertEquals(Level.WARN, e.getLevel()); + assertEquals(Collections.singletonList(BLUE), e.getMarkers()); + assertEquals("w {} {}", e.getMessage()); + assertArrayEquals(new Object[] { A1, A2 }, e.getArgumentArray()); + assertSame(EX, throwableOf(e)); + StackTraceElement caller = e.getCallerData()[0]; + assertEquals(LoggerApiTest.class.getName(), caller.getClassName()); + assertEquals("logSlf4jEventReplaysItsLevelMarkersMessageArgumentsAndThrowable", + caller.getMethodName()); + } + + // ---------------------------------------------------------------- helpers + + /** + * Checks the 15 events appended by the calls of a {@code *Overloads} test: + * the five argument shapes without markers, with the {@code BLUE} marker and + * with the {@code MARKERS} list. + */ + private void assertOverloadEvents(Level level) { + List events = appender.list; + assertEquals(15, events.size()); + for (int i = 0; i < events.size(); i++) { + ILoggingEvent e = events.get(i); + int shape = i % 5; + String call = level + " call #" + i; + assertEquals(call, level, e.getLevel()); + assertEquals(call, logger.getName(), e.getLoggerName()); + assertEquals(call, SHAPE_MESSAGES[shape], e.getMessage()); + assertArrayEquals(call, SHAPE_ARGS[shape], e.getArgumentArray()); + assertSame(call, shape == THROWABLE_SHAPE ? EX : null, throwableOf(e)); + if (i < 5) { + assertNull(call, e.getMarkers()); + } else if (i < 10) { + assertEquals(call, Collections.singletonList(BLUE), e.getMarkers()); + } else { + assertSame(call, MARKERS, e.getMarkers()); + } + } + } + + private static Throwable throwableOf(ILoggingEvent e) { + IThrowableProxy proxy = e.getThrowableProxy(); + return proxy == null ? null : ((ThrowableProxy) proxy).getThrowable(); + } + + private static void assertUnknownReply(ThrowingRunnable check) { + IllegalStateException e = assertThrows(IllegalStateException.class, check); + assertEquals("Unknown FilterReply value: null", e.getMessage()); + } + + private RecordingTurboFilter addTurboFilter(FilterReply reply) { + RecordingTurboFilter filter = new RecordingTurboFilter(reply); + filter.setContext(lc); + filter.start(); + lc.addTurboFilter(filter); + return filter; + } + + /** Replies with a fixed {@link FilterReply} and records what it was asked. */ + static final class RecordingTurboFilter extends TurboFilter { + final FilterReply reply; + int calls; + List markers; + Level level; + String format; + Object[] params; + + RecordingTurboFilter(FilterReply reply) { + this.reply = reply; + } + + @Override + public FilterReply decide(List markers, Logger logger, Level level, + String format, Object[] params, Throwable t) { + calls++; + this.markers = markers; + this.level = level; + this.format = format; + this.params = params; + return reply; + } + + void assertAsked(Level expectedLevel, List expectedMarkers, + String expectedFormat, Object[] expectedParams) { + assertEquals(expectedLevel, level); + assertEquals(expectedMarkers, markers); + assertEquals(expectedFormat, format); + assertArrayEquals(expectedParams, params); + } + } + + /** Records events, computing their caller data while the call is on the stack. */ + static final class CallerDataCapturingAppender extends ListAppender { + @Override + protected void append(ILoggingEvent e) { + e.getCallerData(); + super.append(e); + } + } + + /** A logging facade that passes its own class name as the caller boundary. */ + static final class Facade { + static void log(Logger logger, List markers, int levelInt, String msg, Object... args) { + logger.log(markers, Facade.class.getName(), levelInt, msg, args, EX); + } + } +} From 63247983337a310a4771e33a9709f17a7b10dc10 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:52:21 +0000 Subject: [PATCH 24/79] test: pin Logger.FQCN as the caller boundary of every print overload The overload tests checked level, message, arguments, throwable and markers, but not the caller data, so passing a wrong fqcn from any trace/debug/info/warn/error overload went unnoticed. Each appended event must now name the test method as its caller. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../java/ch/qos/logback/classic/LoggerApiTest.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/logback-android/src/test/java/ch/qos/logback/classic/LoggerApiTest.java b/logback-android/src/test/java/ch/qos/logback/classic/LoggerApiTest.java index 6215226b4..4a74a871d 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/LoggerApiTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/LoggerApiTest.java @@ -29,8 +29,10 @@ import org.junit.After; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.junit.function.ThrowingRunnable; +import org.junit.rules.TestName; import org.slf4j.Marker; import org.slf4j.event.DefaultLoggingEvent; import org.slf4j.helpers.BasicMarkerFactory; @@ -75,6 +77,9 @@ public class LoggerApiTest { Logger logger = lc.getLogger(LoggerApiTest.class); CallerDataCapturingAppender appender = new CallerDataCapturingAppender(); + @Rule + public TestName testName = new TestName(); + @Before public void setUp() { appender.setContext(lc); @@ -491,7 +496,8 @@ public void logSlf4jEventReplaysItsLevelMarkersMessageArgumentsAndThrowable() { /** * Checks the 15 events appended by the calls of a {@code *Overloads} test: * the five argument shapes without markers, with the {@code BLUE} marker and - * with the {@code MARKERS} list. + * with the {@code MARKERS} list. Each event must name the test method as its + * caller, i.e. {@link Logger#FQCN} must be the caller boundary. */ private void assertOverloadEvents(Level level) { List events = appender.list; @@ -505,6 +511,10 @@ private void assertOverloadEvents(Level level) { assertEquals(call, SHAPE_MESSAGES[shape], e.getMessage()); assertArrayEquals(call, SHAPE_ARGS[shape], e.getArgumentArray()); assertSame(call, shape == THROWABLE_SHAPE ? EX : null, throwableOf(e)); + StackTraceElement[] callerData = e.getCallerData(); + assertTrue(call + " has no caller data", callerData.length > 0); + assertEquals(call, LoggerApiTest.class.getName(), callerData[0].getClassName()); + assertEquals(call, testName.getMethodName(), callerData[0].getMethodName()); if (i < 5) { assertNull(call, e.getMarkers()); } else if (i < 10) { From 2a3ae2873b9431f14d964e25f4e45be1002a435e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:40:13 +0000 Subject: [PATCH 25/79] refactor: call Throwable.getSuppressed() directly in ThrowableProxy ThrowableProxy looked up Throwable#getSuppressed reflectively so that it could run on Java < 7. The library's floor is Java 8 / Android API 21, where getSuppressed() always exists (API 19+), so the lookup can never fail: the NoSuchMethodException catch, the "method is null" branch and the IllegalAccessException catch (the method is public) were unreachable, which kept the class below the 100% coverage gate. Call getSuppressed() directly, as upstream logback 1.3 does, and keep a null guard for mocked throwables (a Mockito mock returns null for it). Behavior is unchanged for every real Throwable: getSuppressed() is final and never throws. The new tests pin the null-array guard with a mocked throwable and the ordering and common-frame count of proxied suppressed throwables. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../logback/classic/spi/ThrowableProxy.java | 44 +++++-------------- .../classic/spi/ThrowableProxyTest.java | 40 +++++++++++++++++ 2 files changed, 50 insertions(+), 34 deletions(-) diff --git a/logback-android/src/main/java/ch/qos/logback/classic/spi/ThrowableProxy.java b/logback-android/src/main/java/ch/qos/logback/classic/spi/ThrowableProxy.java index 4d7b53f0f..f7a8e48f2 100644 --- a/logback-android/src/main/java/ch/qos/logback/classic/spi/ThrowableProxy.java +++ b/logback-android/src/main/java/ch/qos/logback/classic/spi/ThrowableProxy.java @@ -17,8 +17,6 @@ import ch.qos.logback.core.CoreConstants; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; import java.util.Collections; import java.util.IdentityHashMap; import java.util.Set; @@ -38,18 +36,6 @@ public class ThrowableProxy implements IThrowableProxy { private transient PackagingDataCalculator packagingDataCalculator; private boolean calculatedPackageData = false; - private static final Method GET_SUPPRESSED_METHOD; - - static { - Method method = null; - try { - method = Throwable.class.getMethod("getSuppressed"); - } catch (NoSuchMethodException e) { - // ignore, will get thrown in Java < 7 - } - GET_SUPPRESSED_METHOD = method; - } - private static final ThrowableProxy[] NO_SUPPRESSED = new ThrowableProxy[0]; private static final StackTraceElementProxy[] NO_STACK_TRACE = new StackTraceElementProxy[0]; @@ -78,26 +64,16 @@ private ThrowableProxy(Throwable throwable, Set visited) { stackTraceElementProxyArray); } - if (GET_SUPPRESSED_METHOD != null) { - // this will only execute on Java >= 7 - try { - Object obj = GET_SUPPRESSED_METHOD.invoke(throwable); - if (obj instanceof Throwable[]) { - Throwable[] throwableSuppressed = (Throwable[]) obj; - if (throwableSuppressed.length > 0) { - suppressed = new ThrowableProxy[throwableSuppressed.length]; - for (int i = 0; i < throwableSuppressed.length; i++) { - this.suppressed[i] = new ThrowableProxy(throwableSuppressed[i], visited); - this.suppressed[i].commonFrames = ThrowableProxyUtil - .findNumberOfCommonFrames(throwableSuppressed[i].getStackTrace(), - stackTraceElementProxyArray); - } - } - } - } catch (IllegalAccessException e) { - // ignore - } catch (InvocationTargetException e) { - // ignore + Throwable[] throwableSuppressed = throwable.getSuppressed(); + // while JDK's implementation of getSuppressed() will always return a non-null array, + // this might not be the case in mocked throwables or in other implementations + if (throwableSuppressed != null && throwableSuppressed.length > 0) { + suppressed = new ThrowableProxy[throwableSuppressed.length]; + for (int i = 0; i < throwableSuppressed.length; i++) { + this.suppressed[i] = new ThrowableProxy(throwableSuppressed[i], visited); + this.suppressed[i].commonFrames = ThrowableProxyUtil + .findNumberOfCommonFrames(throwableSuppressed[i].getStackTrace(), + stackTraceElementProxyArray); } } } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/ThrowableProxyTest.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/ThrowableProxyTest.java index 48c4fe160..92b38a3ae 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/spi/ThrowableProxyTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/ThrowableProxyTest.java @@ -16,8 +16,11 @@ package ch.qos.logback.classic.spi; import static junit.framework.Assert.assertEquals; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assume.assumeTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; import static ch.qos.logback.classic.util.TestHelper.addSuppressed; @@ -194,6 +197,43 @@ public void circularSuppressed() { verifyContains(ex2, "Suppressed: CIRCULAR REFERENCE:java.lang.Exception: Bar"); } + @Test + public void mockedThrowableWithNullSuppressedArrayYieldsNoSuppressedProxies() { + // Throwable.getSuppressed() never returns null for a real throwable, but a + // mocked one does (Mockito's default answer for an array is null) + Throwable t = mock(Exception.class); + doReturn("mocked").when(t).getMessage(); + doReturn(null).when(t).getSuppressed(); + + ThrowableProxy tp = new ThrowableProxy(t); + + assertEquals("mocked", tp.getMessage()); + assertEquals(0, tp.getSuppressed().length); + assertEquals(0, tp.getStackTraceElementProxyArray().length); + assertNull(tp.getCause()); + } + + @Test + public void suppressedThrowablesAreProxiedInOrderWithTheirCommonFrames() { + Exception main = new Exception("main"); + Exception foo = new Exception("Foo"); + Exception bar = new Exception("Bar"); + main.addSuppressed(foo); + main.addSuppressed(bar); + + ThrowableProxy tp = new ThrowableProxy(main); + + IThrowableProxy[] suppressed = tp.getSuppressed(); + assertEquals(2, suppressed.length); + assertEquals("Foo", suppressed[0].getMessage()); + assertEquals("Bar", suppressed[1].getMessage()); + // the three throwables are created on different lines of this method, so + // they share every frame but the topmost one + int expectedCommonFrames = main.getStackTrace().length - 1; + assertEquals(expectedCommonFrames, suppressed[0].getCommonFrames()); + assertEquals(expectedCommonFrames, suppressed[1].getCommonFrames()); + } + void someMethod() throws Exception { throw new Exception("someMethod"); } From b03d01504a845389e1b26d594ae0733db3d66556 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:51:46 +0000 Subject: [PATCH 26/79] test: cover classic spi (CallerData, LoggingEvent, packaging data, ...) Bring every class of ch.qos.logback.classic.spi to 100% line and branch coverage: - CallerData: framework frames (fqcn, org.slf4j.Logger*, log4j Category, listed framework packages) are skipped, maxDepth truncates, a null throwable yields null, naInstance() fields. - ClassPackagingData, StackTraceElementProxy: constructors, the set-once packaging data, and every branch of equals/hashCode. - EventArgUtil, LoggingEvent: a trailing throwable argument becomes the event's throwable and is trimmed from the arguments; every set-once setter throws on a second call; the MDC map is copied from a foreign MDCAdapter; toString, getMdc, context birth time; the private writeObject hook refuses serialization. - PackagingDataCalculator: suppressed frames, a proxy without suppressed array, class resolution through the context class loader, a null or failing one, and Class.forName (including its NoClassDefFoundError and unexpected failures), array classes without package, code sources without location, with Windows-style or folder locations, or failing to render. - ThrowableProxy, ThrowableProxyUtil, STEUtil: packaging data is calculated once, build(), common-frame counting when either array runs out, packaging-data suffixes, the deprecated subjoinSTEPArray. - TurboFilterList: empty chain, first DENY/ACCEPT wins, all-NEUTRAL, and a single filter removed concurrently. - LoggerRemoteView: keeps the name and the context's remote view; its assert fires on a context without a remote view (Gradle runs tests with -ea). PackagingDataCalculatorTest now also restores the thread's context class loader after each test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../logback/classic/spi/CallerDataTest.java | 88 ++++- .../classic/spi/ClassPackagingDataTest.java | 97 +++++ .../logback/classic/spi/EventArgUtilTest.java | 83 +++++ .../classic/spi/LoggerRemoteViewTest.java | 70 ++++ .../logback/classic/spi/LoggingEventTest.java | 240 ++++++++++++ .../PackagingDataCalculatorFailingInit.java | 30 ++ .../spi/PackagingDataCalculatorSubject.java | 23 ++ .../spi/PackagingDataCalculatorTest.java | 352 ++++++++++++++++++ .../qos/logback/classic/spi/STEUtilTest.java | 68 ++++ .../spi/StackTraceElementProxyTest.java | 106 ++++++ .../classic/spi/ThrowableProxyTest.java | 50 +++ .../classic/spi/ThrowableProxyUtilTest.java | 156 ++++++++ .../classic/spi/TurboFilterListTest.java | 148 ++++++++ 13 files changed, 1510 insertions(+), 1 deletion(-) create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/spi/ClassPackagingDataTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/spi/EventArgUtilTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/spi/LoggerRemoteViewTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/spi/PackagingDataCalculatorFailingInit.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/spi/PackagingDataCalculatorSubject.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/spi/STEUtilTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/spi/StackTraceElementProxyTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/spi/ThrowableProxyUtilTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/spi/TurboFilterListTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/CallerDataTest.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/CallerDataTest.java index 0315a2bff..45e0e2048 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/spi/CallerDataTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/CallerDataTest.java @@ -18,11 +18,20 @@ import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertNotNull; import static junit.framework.Assert.assertTrue; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import org.junit.Test; public class CallerDataTest { + private static final String FQCN = "com.acme.logging.MyLogger"; + private static final List FRAMEWORK_PACKAGES = Arrays.asList("com.acme.wrapper", "org.example.facade"); @Test public void testBasic() { @@ -46,5 +55,82 @@ public void testDeferredProcessing() { assertNotNull(cda); assertEquals(0, cda.length); } - + + @Test + public void extractReturnsNullWithoutThrowable() { + assertNull(CallerData.extract(null, FQCN, 10, FRAMEWORK_PACKAGES)); + } + + @Test + public void callerIsTheFirstFrameAfterAllFrameworkFrames() { + StackTraceElement caller = ste("com.app.Service"); + StackTraceElement main = ste("com.app.Main"); + Throwable t = throwableWithFrames( + ste(FQCN), + ste("org.slf4j.LoggerFactoryHelper"), // starts with org.slf4j.Logger + ste("org.apache.log4j.Category"), // log4j-over-slf4j + ste("com.acme.wrapper.LogWrapper"), // in the framework package list + ste("org.example.facade.sub.LogFacade"), // sub-package of a listed package + caller, + main); + + StackTraceElement[] cda = CallerData.extract(t, FQCN, 10, FRAMEWORK_PACKAGES); + + assertArrayEquals(new StackTraceElement[] {caller, main}, cda); + } + + @Test + public void callerDataIsTruncatedToMaxDepth() { + StackTraceElement caller = ste("com.app.Service"); + Throwable t = throwableWithFrames(ste(FQCN), caller, ste("com.app.Main"), ste("com.app.Launcher")); + + StackTraceElement[] cda = CallerData.extract(t, FQCN, 1, FRAMEWORK_PACKAGES); + + assertArrayEquals(new StackTraceElement[] {caller}, cda); + } + + @Test + public void frameworkFramesBelowTheCallerDoNotMoveTheCaller() { + StackTraceElement caller = ste("com.app.Service"); + StackTraceElement wrapperBelow = ste("com.acme.wrapper.Dispatcher"); + Throwable t = throwableWithFrames(ste(FQCN), caller, wrapperBelow); + + StackTraceElement[] cda = CallerData.extract(t, FQCN, 10, FRAMEWORK_PACKAGES); + + assertArrayEquals(new StackTraceElement[] {caller, wrapperBelow}, cda); + } + + @Test + public void onlyClassesOfListedPackagesAreInFrameworkSpace() { + assertTrue(CallerData.isInFrameworkSpace("org.example.facade.X", FQCN, FRAMEWORK_PACKAGES)); + assertFalse(CallerData.isInFrameworkSpace("com.app.Service", FQCN, FRAMEWORK_PACKAGES)); + assertFalse(CallerData.isInFrameworkSpace("com.app.Service", FQCN, Collections.emptyList())); + assertFalse(CallerData.isInFrameworkSpace("com.app.Service", FQCN, null)); + } + + @Test + public void naInstanceHasUnavailableFields() { + StackTraceElement na = CallerData.naInstance(); + + assertEquals(CallerData.NA, na.getClassName()); + assertEquals(CallerData.NA, na.getMethodName()); + assertEquals(CallerData.NA, na.getFileName()); + assertEquals(CallerData.LINE_NA, na.getLineNumber()); + } + + @Test + public void publicConstructorIsAvailable() { + // the class only has static members, but its implicit public constructor is API + assertNotNull(new CallerData()); + } + + private static StackTraceElement ste(String className) { + return new StackTraceElement(className, "method", "File.java", 42); + } + + private static Throwable throwableWithFrames(StackTraceElement... frames) { + Throwable t = new Throwable(); + t.setStackTrace(frames); + return t; + } } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/ClassPackagingDataTest.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/ClassPackagingDataTest.java new file mode 100644 index 000000000..1f7230bc0 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/ClassPackagingDataTest.java @@ -0,0 +1,97 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.spi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class ClassPackagingDataTest { + + @Test + public void twoArgConstructorCreatesExactData() { + ClassPackagingData cpd = new ClassPackagingData("foo.jar", "1.2"); + + assertEquals("foo.jar", cpd.getCodeLocation()); + assertEquals("1.2", cpd.getVersion()); + assertTrue(cpd.isExact()); + } + + @Test + public void threeArgConstructorKeepsExactness() { + ClassPackagingData cpd = new ClassPackagingData("foo.jar", "1.2", false); + + assertEquals("foo.jar", cpd.getCodeLocation()); + assertEquals("1.2", cpd.getVersion()); + assertFalse(cpd.isExact()); + } + + @Test + public void hashCodeDependsOnlyOnCodeLocation() { + assertEquals(31, new ClassPackagingData(null, "1.2").hashCode()); + assertEquals(31 + "foo.jar".hashCode(), new ClassPackagingData("foo.jar", "1.2").hashCode()); + assertEquals(new ClassPackagingData("foo.jar", "1.2", true).hashCode(), + new ClassPackagingData("foo.jar", "3.4", false).hashCode()); + } + + @Test + public void equalsIsReflexive() { + ClassPackagingData cpd = new ClassPackagingData("foo.jar", "1.2"); + assertTrue(cpd.equals(cpd)); + } + + @Test + public void notEqualToNull() { + assertFalse(new ClassPackagingData("foo.jar", "1.2").equals(null)); + } + + @Test + public void notEqualToInstanceOfAnotherClass() { + ClassPackagingData subclassInstance = new ClassPackagingData("foo.jar", "1.2") { + private static final long serialVersionUID = 1L; + }; + assertFalse(new ClassPackagingData("foo.jar", "1.2").equals(subclassInstance)); + assertFalse(new ClassPackagingData("foo.jar", "1.2").equals("foo.jar")); + } + + @Test + public void equalWhenAllFieldsAreEqual() { + assertEquals(new ClassPackagingData("foo.jar", "1.2", false), new ClassPackagingData("foo.jar", "1.2", false)); + assertEquals(new ClassPackagingData(null, null), new ClassPackagingData(null, null)); + } + + @Test + public void codeLocationIsCompared() { + assertNotEquals(new ClassPackagingData(null, "1.2"), new ClassPackagingData("foo.jar", "1.2")); + assertNotEquals(new ClassPackagingData("foo.jar", "1.2"), new ClassPackagingData(null, "1.2")); + assertNotEquals(new ClassPackagingData("foo.jar", "1.2"), new ClassPackagingData("bar.jar", "1.2")); + } + + @Test + public void exactnessIsCompared() { + assertNotEquals(new ClassPackagingData("foo.jar", "1.2", true), new ClassPackagingData("foo.jar", "1.2", false)); + } + + @Test + public void versionIsCompared() { + assertNotEquals(new ClassPackagingData("foo.jar", null), new ClassPackagingData("foo.jar", "1.2")); + assertNotEquals(new ClassPackagingData("foo.jar", "1.2"), new ClassPackagingData("foo.jar", null)); + assertNotEquals(new ClassPackagingData("foo.jar", "1.2"), new ClassPackagingData("foo.jar", "3.4")); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/EventArgUtilTest.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/EventArgUtilTest.java new file mode 100644 index 000000000..712385f25 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/EventArgUtilTest.java @@ -0,0 +1,83 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.spi; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class EventArgUtilTest { + + @Test + public void noThrowableIsExtractedFromNullOrEmptyArray() { + assertNull(EventArgUtil.extractThrowable(null)); + assertNull(EventArgUtil.extractThrowable(new Object[0])); + } + + @Test + public void noThrowableIsExtractedWhenLastArgumentIsNotAThrowable() { + Exception notLast = new Exception("not last"); + assertNull(EventArgUtil.extractThrowable(new Object[] {notLast, "a"})); + } + + @Test + public void lastArgumentIsExtractedWhenItIsAThrowable() { + Exception last = new Exception("last"); + assertSame(last, EventArgUtil.extractThrowable(new Object[] {"a", last})); + } + + @Test + public void trimmedCopyDropsTheLastArgument() { + Object[] args = new Object[] {"a", 2, new Exception("last")}; + + Object[] trimmed = EventArgUtil.trimmedCopy(args); + + assertArrayEquals(new Object[] {"a", 2}, trimmed); + assertEquals("the input array is left untouched", 3, args.length); + } + + @Test + public void trimmedCopyRejectsNullArray() { + IllegalStateException e = assertThrows(IllegalStateException.class, () -> EventArgUtil.trimmedCopy(null)); + assertEquals("non-sensical empty or null argument array", e.getMessage()); + } + + @Test + public void trimmedCopyRejectsEmptyArray() { + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> EventArgUtil.trimmedCopy(new Object[0])); + assertEquals("non-sensical empty or null argument array", e.getMessage()); + } + + @Test + public void extractionSucceedsOnlyForNonNullThrowable() { + assertTrue(EventArgUtil.successfulExtraction(new Exception())); + assertFalse(EventArgUtil.successfulExtraction(null)); + } + + @Test + public void publicConstructorIsAvailable() { + // the class only has static members, but its implicit public constructor is API + assertNotNull(new EventArgUtil()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggerRemoteViewTest.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggerRemoteViewTest.java new file mode 100644 index 000000000..17d8c2b58 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggerRemoteViewTest.java @@ -0,0 +1,70 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.spi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assume.assumeFalse; +import static org.junit.Assume.assumeTrue; + +import org.junit.Test; + +import ch.qos.logback.classic.LoggerContext; + +public class LoggerRemoteViewTest { + + @Test + public void keepsTheNameAndTheRemoteViewOfTheContext() { + LoggerContext lc = new LoggerContext(); + lc.setName("ctx"); + + LoggerRemoteView view = new LoggerRemoteView("a.b.C", lc); + + assertEquals("a.b.C", view.getName()); + assertNotNull(view.getLoggerContextView()); + assertSame(lc.getLoggerContextRemoteView(), view.getLoggerContextView()); + } + + @Test + public void contextWithoutRemoteViewFailsTheAssertionWhenAssertionsAreEnabled() { + // Gradle runs unit tests with assertions enabled (-ea) + assumeTrue(LoggerRemoteView.class.desiredAssertionStatus()); + + AssertionError e = assertThrows(AssertionError.class, + () -> new LoggerRemoteView("a.b.C", new ContextWithoutRemoteView())); + assertNull(e.getMessage()); + } + + @Test + public void contextWithoutRemoteViewYieldsNullViewWhenAssertionsAreDisabled() { + assumeFalse(LoggerRemoteView.class.desiredAssertionStatus()); + + LoggerRemoteView view = new LoggerRemoteView("a.b.C", new ContextWithoutRemoteView()); + + assertEquals("a.b.C", view.getName()); + assertNull(view.getLoggerContextView()); + } + + static class ContextWithoutRemoteView extends LoggerContext { + @Override + public LoggerContextVO getLoggerContextRemoteView() { + return null; + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggingEventTest.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggingEventTest.java index 1f9e40834..124242c37 100755 --- a/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggingEventTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggingEventTest.java @@ -20,9 +20,33 @@ import ch.qos.logback.classic.LoggerContext; import org.junit.Before; import org.junit.Test; +import org.mockito.MockedStatic; +import org.slf4j.MDC; +import org.slf4j.Marker; +import org.slf4j.MarkerFactory; +import org.slf4j.spi.MDCAdapter; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.NotSerializableException; +import java.io.ObjectOutputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; public class LoggingEventTest { @@ -89,4 +113,220 @@ public void testNoFormattingWithoutArgs() { assertNull(event.formattedMessage); assertEquals(message, event.getFormattedMessage()); } + + @Test + public void trailingThrowableArgumentBecomesTheEventThrowable() { + Exception ex = new Exception("boom"); + Object[] argArray = new Object[] {12, ex}; + + LoggingEvent event = new LoggingEvent("", logger, Level.INFO, "x={}", null, argArray); + + assertArrayEquals(new Object[] {12}, event.getArgumentArray()); + assertEquals(2, argArray.length); + assertSame(ex, ((ThrowableProxy) event.getThrowableProxy()).getThrowable()); + assertEquals("x=12", event.getFormattedMessage()); + } + + @Test + public void explicitThrowableLeavesTheArgumentsUntouched() { + Exception explicit = new Exception("explicit"); + Exception trailing = new Exception("trailing"); + Object[] argArray = new Object[] {12, trailing}; + + LoggingEvent event = new LoggingEvent("", logger, Level.INFO, "x={} {}", explicit, argArray); + + assertSame(argArray, event.getArgumentArray()); + assertSame(explicit, ((ThrowableProxy) event.getThrowableProxy()).getThrowable()); + } + + @Test + public void packagingDataIsCalculatedWhenEnabledInTheContext() { + loggerContext.setPackagingDataEnabled(true); + + LoggingEvent event = new LoggingEvent("", logger, Level.INFO, "msg", new Exception("boom"), null); + + StackTraceElementProxy[] steps = event.getThrowableProxy().getStackTraceElementProxyArray(); + assertTrue(steps.length > 0); + for (StackTraceElementProxy step : steps) { + assertNotNull(step.getClassPackagingData()); + } + } + + @Test + public void packagingDataIsNotCalculatedByDefault() { + LoggingEvent event = new LoggingEvent("", logger, Level.INFO, "msg", new Exception("boom"), null); + + StackTraceElementProxy[] steps = event.getThrowableProxy().getStackTraceElementProxyArray(); + assertTrue(steps.length > 0); + for (StackTraceElementProxy step : steps) { + assertNull(step.getClassPackagingData()); + } + } + + @Test + public void argumentArrayCanBeSetOnlyOnce() { + LoggingEvent event = new LoggingEvent(); + Object[] first = new Object[] {1}; + event.setArgumentArray(first); + + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> event.setArgumentArray(new Object[] {2})); + assertEquals("argArray has been already set", e.getMessage()); + assertSame(first, event.getArgumentArray()); + } + + @Test + public void threadNameCanBeSetOnlyOnce() { + LoggingEvent event = new LoggingEvent(); + event.setThreadName("first"); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> event.setThreadName("second")); + assertEquals("threadName has been already set", e.getMessage()); + assertEquals("first", event.getThreadName()); + } + + @Test + public void threadNameCannotBeSetAfterItWasComputed() { + LoggingEvent event = new LoggingEvent(); + assertEquals(Thread.currentThread().getName(), event.getThreadName()); + + assertThrows(IllegalStateException.class, () -> event.setThreadName("other")); + } + + @Test + public void throwableProxyCanBeSetOnlyOnce() { + LoggingEvent event = new LoggingEvent(); + ThrowableProxy first = new ThrowableProxy(new Exception("first")); + event.setThrowableProxy(first); + + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> event.setThrowableProxy(new ThrowableProxy(new Exception("second")))); + assertEquals("ThrowableProxy has been already set.", e.getMessage()); + assertSame(first, event.getThrowableProxy()); + } + + @Test + public void messageCanBeSetOnlyOnce() { + LoggingEvent event = new LoggingEvent(); + event.setMessage("first"); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> event.setMessage("second")); + assertEquals("The message for this event has been set already.", e.getMessage()); + assertEquals("first", event.getMessage()); + } + + @Test + public void levelCanBeSetOnlyOnce() { + LoggingEvent event = new LoggingEvent(); + event.setLevel(Level.WARN); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> event.setLevel(Level.ERROR)); + assertEquals("The level has been already set for this event.", e.getMessage()); + assertEquals(Level.WARN, event.getLevel()); + } + + @Test + public void markersCanBeSetOnlyOnce() { + LoggingEvent event = new LoggingEvent(); + List first = Collections.singletonList(MarkerFactory.getMarker("FIRST")); + event.setMarkers(first); + + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> event.setMarkers(Collections.singletonList(MarkerFactory.getMarker("SECOND")))); + assertEquals("The marker has been already set for this event.", e.getMessage()); + assertSame(first, event.getMarkers()); + } + + @Test + public void mdcPropertyMapCanBeSetOnlyOnce() { + LoggingEvent event = new LoggingEvent(); + Map first = Collections.singletonMap("k", "first"); + event.setMDCPropertyMap(first); + + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> event.setMDCPropertyMap(Collections.singletonMap("k", "second"))); + assertEquals("The MDCPropertyMap has been already set for this event.", e.getMessage()); + assertSame(first, event.getMDCPropertyMap()); + } + + @Test + public void mdcIsASynonymForMdcPropertyMap() { + LoggingEvent event = new LoggingEvent(); + Map map = Collections.singletonMap("k", "v"); + event.setMDCPropertyMap(map); + + assertSame(map, event.getMdc()); + } + + @Test + public void mdcPropertyMapIsCopiedFromAForeignMdcAdapter() { + Map context = new HashMap(); + context.put("user", "alice"); + MDCAdapter foreignAdapter = mock(MDCAdapter.class); + when(foreignAdapter.getCopyOfContextMap()).thenReturn(context); + + try (MockedStatic mdc = mockStatic(MDC.class)) { + mdc.when(MDC::getMDCAdapter).thenReturn(foreignAdapter); + LoggingEvent event = new LoggingEvent("", logger, Level.INFO, "msg", null, null); + + assertEquals(context, event.getMDCPropertyMap()); + } + } + + @Test + public void mdcPropertyMapIsEmptyWhenAForeignMdcAdapterHasNoContext() { + MDCAdapter foreignAdapter = mock(MDCAdapter.class); + when(foreignAdapter.getCopyOfContextMap()).thenReturn(null); + + try (MockedStatic mdc = mockStatic(MDC.class)) { + mdc.when(MDC::getMDCAdapter).thenReturn(foreignAdapter); + LoggingEvent event = new LoggingEvent("", logger, Level.INFO, "msg", null, null); + + assertEquals(Collections.emptyMap(), event.getMDCPropertyMap()); + } + } + + @Test + public void contextBirthTimeComesFromTheContextView() { + LoggingEvent event = new LoggingEvent("", logger, Level.INFO, "msg", null, null); + + assertEquals(loggerContext.getBirthTime(), event.getContextBirthTime()); + } + + @Test + public void toStringShowsLevelAndFormattedMessage() { + LoggingEvent event = new LoggingEvent("", logger, Level.WARN, "x={}", null, new Object[] {12}); + + assertEquals("[WARN] x=12", event.toString()); + } + + @Test + public void eventIsNotSerializable() throws IOException { + LoggingEvent event = new LoggingEvent("", logger, Level.INFO, "msg", null, null); + ObjectOutputStream oos = new ObjectOutputStream(new ByteArrayOutputStream()); + try { + assertThrows(NotSerializableException.class, () -> oos.writeObject(event)); + } finally { + oos.close(); + } + } + + @Test + public void serializationHookRefusesToWriteTheEvent() throws Exception { + // LoggingEvent is not Serializable, so only reflection reaches its private + // writeObject hook (kept from upstream logback) + LoggingEvent event = new LoggingEvent("", logger, Level.INFO, "msg", null, null); + Method writeObject = LoggingEvent.class.getDeclaredMethod("writeObject", ObjectOutputStream.class); + writeObject.setAccessible(true); + ObjectOutputStream oos = new ObjectOutputStream(new ByteArrayOutputStream()); + try { + InvocationTargetException e = assertThrows(InvocationTargetException.class, + () -> writeObject.invoke(event, oos)); + assertTrue(e.getCause() instanceof UnsupportedOperationException); + assertEquals(LoggingEvent.class + " does not support serialization. " + + "Use LoggerEventVO instance instead. See also LoggerEventVO.build method.", e.getCause().getMessage()); + } finally { + oos.close(); + } + } } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/PackagingDataCalculatorFailingInit.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/PackagingDataCalculatorFailingInit.java new file mode 100644 index 000000000..415f646bc --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/PackagingDataCalculatorFailingInit.java @@ -0,0 +1,30 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.spi; + +/** + * A class whose static initialization always fails, so that once a first attempt + * failed, {@code Class.forName} throws a {@link NoClassDefFoundError} for it. Used by + * {@link PackagingDataCalculatorTest}. + */ +public class PackagingDataCalculatorFailingInit { + + static final Object VALUE = fail(); + + private static Object fail() { + throw new IllegalStateException("initialization of PackagingDataCalculatorFailingInit always fails"); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/PackagingDataCalculatorSubject.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/PackagingDataCalculatorSubject.java new file mode 100644 index 000000000..e838f9747 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/PackagingDataCalculatorSubject.java @@ -0,0 +1,23 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.spi; + +/** + * A class that {@link PackagingDataCalculatorTest} defines again in its own class + * loaders, with code sources of its choosing. + */ +public class PackagingDataCalculatorSubject { +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/PackagingDataCalculatorTest.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/PackagingDataCalculatorTest.java index 66423c69a..1361a314b 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/spi/PackagingDataCalculatorTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/PackagingDataCalculatorTest.java @@ -17,11 +17,30 @@ import static junit.framework.Assert.assertNotNull; import static junit.framework.Assert.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintStream; +import java.io.UnsupportedEncodingException; +import java.lang.reflect.Method; import java.net.MalformedURLException; import java.net.URL; import java.net.URLClassLoader; +import java.net.URLConnection; +import java.net.URLStreamHandler; +import java.security.CodeSource; +import java.security.ProtectionDomain; +import java.security.cert.Certificate; +import org.junit.After; +import org.junit.Before; import org.junit.Ignore; import org.junit.Test; @@ -29,6 +48,18 @@ public class PackagingDataCalculatorTest { + private ClassLoader originalContextClassLoader; + + @Before + public void saveContextClassLoader() { + originalContextClassLoader = Thread.currentThread().getContextClassLoader(); + } + + @After + public void restoreContextClassLoader() { + Thread.currentThread().setContextClassLoader(originalContextClassLoader); + } + public void verify(ThrowableProxy tp) { for (StackTraceElementProxy step : tp.getStackTraceElementProxyArray()) { if (step != null) { @@ -122,7 +153,328 @@ public void noClassDefFoundError_LBCLASSIC_125Test() PackagingDataCalculator pdc = tp.getPackagingDataCalculator(); // NoClassDefFoundError should be caught pdc.calculate(tp); + assertNotAvailable(stepArray[0].getClassPackagingData()); + } + + @Test + public void suppressedThrowablesGetPackagingData() { + Exception main = new Exception("main"); + main.addSuppressed(new Exception("suppressed")); + ThrowableProxy tp = new ThrowableProxy(main); + + new PackagingDataCalculator().calculate(tp); + + IThrowableProxy suppressed = tp.getSuppressed()[0]; + assertTrue(suppressed.getStackTraceElementProxyArray().length > 0); + for (StackTraceElementProxy step : suppressed.getStackTraceElementProxyArray()) { + assertNotNull(step.getClassPackagingData()); + } + } + + @Test + public void proxyWithoutSuppressedArrayIsSupported() { + StackTraceElementProxy step = step(Test.class.getName()); + DummyThrowableProxy tp = proxyOf(step); + tp.setSuppressed(null); + + new PackagingDataCalculator().calculate(tp); + + assertPackagingDataOf(Test.class, step.getClassPackagingData()); + } + + @Test + public void framesOfTheSameClassShareCachedPackagingData() { + StackTraceElementProxy first = step(Test.class.getName()); + StackTraceElementProxy second = step(Test.class.getName()); + + new PackagingDataCalculator().calculate(proxyOf(first, second)); + + assertSame(first.getClassPackagingData(), second.getClassPackagingData()); + } + + @Test + public void classIsResolvedWithClassForNameWhenThereIsNoContextClassLoader() { + Thread.currentThread().setContextClassLoader(null); + StackTraceElementProxy step = step(Test.class.getName()); + + new PackagingDataCalculator().calculate(proxyOf(step)); + + assertPackagingDataOf(Test.class, step.getClassPackagingData()); + } + + @Test + public void classUnknownToTheContextClassLoaderIsResolvedWithClassForName() throws IOException { + URLClassLoader bootstrapOnly = new URLClassLoader(new URL[0], null); + try { + Thread.currentThread().setContextClassLoader(bootstrapOnly); + StackTraceElementProxy step = step(Test.class.getName()); + + new PackagingDataCalculator().calculate(proxyOf(step)); + + assertPackagingDataOf(Test.class, step.getClassPackagingData()); + } finally { + bootstrapOnly.close(); + } + } + + @Test + public void failingContextClassLoaderIsReportedAndClassForNameIsUsed() throws Exception { + Thread.currentThread().setContextClassLoader(new ClassLoader(null) { + @Override + public Class loadClass(String name) { + throw new IllegalStateException("unexpected loader failure"); + } + }); + StackTraceElementProxy step = step(Test.class.getName()); + + String err = calculateCapturingStdErr(proxyOf(step)); + + assertTrue(err, err.contains("java.lang.IllegalStateException: unexpected loader failure")); + assertPackagingDataOf(Test.class, step.getClassPackagingData()); + } + + @Test + public void arrayClassHasNoPackageAndThusNoVersion() { + // the application class loader cannot load an array class, Class.forName can + StackTraceElementProxy step = step(String[].class.getName()); + + new PackagingDataCalculator().calculate(proxyOf(step)); + + ClassPackagingData cpd = step.getClassPackagingData(); + assertEquals("na", cpd.getVersion()); + assertEquals("na", cpd.getCodeLocation()); + assertFalse(cpd.isExact()); + } + + @Test + public void classWhoseInitializationFailedHasNoPackagingData() { + Thread.currentThread().setContextClassLoader(null); + String className = PackagingDataCalculatorFailingInit.class.getName(); + try { + Class.forName(className); + } catch (ClassNotFoundException e) { + throw new AssertionError(e); + } catch (LinkageError expected) { + // ExceptionInInitializerError the first time, NoClassDefFoundError afterwards + } + StackTraceElementProxy step = step(className); + + // Class.forName now throws NoClassDefFoundError, which must be swallowed + new PackagingDataCalculator().calculate(proxyOf(step)); + + assertNotAvailable(step.getClassPackagingData()); + } + + @Test + public void unexpectedClassForNameFailureIsReportedAndSwallowed() throws Exception { + Thread.currentThread().setContextClassLoader(null); + // a mocked frame without class name makes Class.forName throw a NullPointerException + StackTraceElement nameless = mock(StackTraceElement.class); + doReturn(null).when(nameless).getClassName(); + StackTraceElementProxy step = new StackTraceElementProxy(nameless); + + String err = calculateCapturingStdErr(proxyOf(step)); + + assertTrue(err, err.contains("java.lang.NullPointerException")); + assertNotAvailable(step.getClassPackagingData()); + } + + @Test + public void codeSourceWithoutLocationHasNoCodeLocation() throws Exception { + useSubjectLoader(new CodeSource(null, (Certificate[]) null)); + StackTraceElementProxy step = step(PackagingDataCalculatorSubject.class.getName()); + + new PackagingDataCalculator().calculate(proxyOf(step)); + + assertEquals("na", step.getClassPackagingData().getCodeLocation()); + } + @Test + public void jarNameIsExtractedFromWindowsStyleLocation() throws Exception { + useSubjectLoader(codeSourceAt("file:C:\\java\\lib\\subject-1.0.jar")); + StackTraceElementProxy step = step(PackagingDataCalculatorSubject.class.getName()); + + new PackagingDataCalculator().calculate(proxyOf(step)); + + assertEquals("subject-1.0.jar", step.getClassPackagingData().getCodeLocation()); + } + + @Test + public void jarNameIsExtractedFromUrlStyleLocation() throws Exception { + useSubjectLoader(codeSourceAt("file:/opt/lib/subject-1.0.jar")); + StackTraceElementProxy step = step(PackagingDataCalculatorSubject.class.getName()); + + new PackagingDataCalculator().calculate(proxyOf(step)); + + assertEquals("subject-1.0.jar", step.getClassPackagingData().getCodeLocation()); + } + + @Test + public void folderNameIsExtractedFromFolderLocation() throws Exception { + useSubjectLoader(codeSourceAt("file:/opt/app/classes/")); + StackTraceElementProxy step = step(PackagingDataCalculatorSubject.class.getName()); + + new PackagingDataCalculator().calculate(proxyOf(step)); + + assertEquals("classes/", step.getClassPackagingData().getCodeLocation()); + } + + @Test + public void failureWhileReadingTheCodeLocationIsSwallowed() throws Exception { + LocationHandler handler = new LocationHandler("file:/opt/lib/subject-1.0.jar"); + SubjectClassLoader loader = useSubjectLoader(new CodeSource(handler.url(), (Certificate[]) null)); + loader.loadClass(PackagingDataCalculatorSubject.class.getName()); + handler.failFromNowOn(); + StackTraceElementProxy step = step(PackagingDataCalculatorSubject.class.getName()); + + new PackagingDataCalculator().calculate(proxyOf(step)); + + assertEquals("na", step.getClassPackagingData().getCodeLocation()); + } + + @Test + public void givenClassLoaderIsTriedBeforeTheContextClassLoader() throws Exception { + // populateFrames() only ever passes a null class loader (the port has no + // Reflection.getCallerClass()), so reach the non-null case reflectively + SubjectClassLoader loader = new SubjectClassLoader(new CodeSource(null, (Certificate[]) null)); + Method bestEffortLoadClass = PackagingDataCalculator.class.getDeclaredMethod("bestEffortLoadClass", + ClassLoader.class, String.class); + bestEffortLoadClass.setAccessible(true); + + Class type = (Class) bestEffortLoadClass.invoke(new PackagingDataCalculator(), loader, + PackagingDataCalculatorSubject.class.getName()); + + assertSame(loader, type.getClassLoader()); + assertNotSame(PackagingDataCalculatorSubject.class, type); + } + + private static StackTraceElementProxy step(String className) { + return new StackTraceElementProxy(new StackTraceElement(className, "method", "File.java", 1)); + } + + private static DummyThrowableProxy proxyOf(StackTraceElementProxy... steps) { + DummyThrowableProxy tp = new DummyThrowableProxy(); + tp.setClassName("java.lang.Exception"); + tp.setStackTraceElementProxyArray(steps); + tp.setSuppressed(new IThrowableProxy[0]); + return tp; + } + + private static String calculateCapturingStdErr(IThrowableProxy tp) throws UnsupportedEncodingException { + ByteArrayOutputStream err = new ByteArrayOutputStream(); + PrintStream originalErr = System.err; + try { + System.setErr(new PrintStream(err, true, "UTF-8")); + new PackagingDataCalculator().calculate(tp); + } finally { + System.setErr(originalErr); + } + return err.toString("UTF-8"); } + private static void assertNotAvailable(ClassPackagingData cpd) { + assertEquals("na", cpd.getCodeLocation()); + assertEquals("na", cpd.getVersion()); + assertFalse(cpd.isExact()); + } + + /** + * Asserts the packaging data of a class loaded from a jar by the application class loader. + */ + private static void assertPackagingDataOf(Class type, ClassPackagingData cpd) { + String location = type.getProtectionDomain().getCodeSource().getLocation().toString(); + assertTrue(location + " is not a jar", location.endsWith(".jar")); + assertEquals(location.substring(location.lastIndexOf('/') + 1), cpd.getCodeLocation()); + String version = type.getPackage().getImplementationVersion(); + assertEquals(version == null ? "na" : version, cpd.getVersion()); + assertFalse(cpd.isExact()); + } + + private SubjectClassLoader useSubjectLoader(CodeSource codeSource) { + SubjectClassLoader loader = new SubjectClassLoader(codeSource); + Thread.currentThread().setContextClassLoader(loader); + return loader; + } + + private static CodeSource codeSourceAt(String externalForm) throws MalformedURLException { + return new CodeSource(new LocationHandler(externalForm).url(), (Certificate[]) null); + } + + /** + * Makes a URL whose string form is exactly the given text, independently of the + * platform's URL parsing, and which can be made to fail when turned into a string. + */ + static class LocationHandler extends URLStreamHandler { + private final String externalForm; + private volatile boolean failing; + + LocationHandler(String externalForm) { + this.externalForm = externalForm; + } + + URL url() throws MalformedURLException { + return new URL("test", null, -1, "/location", this); + } + + void failFromNowOn() { + failing = true; + } + + @Override + protected String toExternalForm(URL u) { + if (failing) { + throw new IllegalStateException("cannot render location"); + } + return externalForm; + } + + @Override + protected URLConnection openConnection(URL u) throws IOException { + throw new IOException("not supported"); + } + } + + /** + * Defines its own copy of {@link PackagingDataCalculatorSubject} with the given code + * source, and delegates every other class to the class loader of this test. + */ + static class SubjectClassLoader extends ClassLoader { + private final ProtectionDomain protectionDomain; + private Class subject; + + SubjectClassLoader(CodeSource codeSource) { + super(PackagingDataCalculatorTest.class.getClassLoader()); + this.protectionDomain = new ProtectionDomain(codeSource, null); + } + + @Override + protected synchronized Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!PackagingDataCalculatorSubject.class.getName().equals(name)) { + return super.loadClass(name, resolve); + } + if (subject == null) { + byte[] bytes = readClassBytes(name); + subject = defineClass(name, bytes, 0, bytes.length, protectionDomain); + } + return subject; + } + + private byte[] readClassBytes(String name) throws ClassNotFoundException { + String resource = name.replace('.', '/') + ".class"; + try (InputStream in = getParent().getResourceAsStream(resource)) { + if (in == null) { + throw new ClassNotFoundException(name); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int n; + while ((n = in.read(buffer)) != -1) { + out.write(buffer, 0, n); + } + return out.toByteArray(); + } catch (IOException e) { + throw new ClassNotFoundException(name, e); + } + } + } } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/STEUtilTest.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/STEUtilTest.java new file mode 100644 index 000000000..c0befb185 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/STEUtilTest.java @@ -0,0 +1,68 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.spi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import org.junit.Test; + +public class STEUtilTest { + + private static final StackTraceElement A = ste("A"); + private static final StackTraceElement B = ste("B"); + private static final StackTraceElement C = ste("C"); + private static final StackTraceElement X = ste("X"); + + @Test + public void noCommonFramesWithoutOtherArray() { + assertEquals(0, STEUtil.findNumberOfCommonFrames(new StackTraceElement[] {A, B}, null)); + } + + @Test + public void commonFramesAreCountedFromTheBottomUntilTheFirstDifference() { + assertEquals(2, STEUtil.findNumberOfCommonFrames(new StackTraceElement[] {X, B, C}, steps(A, B, C))); + assertEquals(0, STEUtil.findNumberOfCommonFrames(new StackTraceElement[] {A, B, X}, steps(A, B, C))); + } + + @Test + public void countStopsWhenTheOtherArrayIsExhausted() { + assertEquals(2, STEUtil.findNumberOfCommonFrames(new StackTraceElement[] {A, B, C}, steps(B, C))); + } + + @Test + public void countStopsWhenTheFirstArrayIsExhausted() { + assertEquals(2, STEUtil.findNumberOfCommonFrames(new StackTraceElement[] {B, C}, steps(A, B, C))); + } + + @Test + public void publicConstructorIsAvailable() { + // the class only has static members, but its implicit public constructor is API + assertNotNull(new STEUtil()); + } + + private static StackTraceElement ste(String className) { + return new StackTraceElement(className, "method", className + ".java", 1); + } + + private static StackTraceElementProxy[] steps(StackTraceElement... stes) { + StackTraceElementProxy[] steps = new StackTraceElementProxy[stes.length]; + for (int i = 0; i < stes.length; i++) { + steps[i] = new StackTraceElementProxy(stes[i]); + } + return steps; + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/StackTraceElementProxyTest.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/StackTraceElementProxyTest.java new file mode 100644 index 000000000..36957845e --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/StackTraceElementProxyTest.java @@ -0,0 +1,106 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.spi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class StackTraceElementProxyTest { + + private final StackTraceElement ste = new StackTraceElement("com.acme.Foo", "bar", "Foo.java", 12); + private final StackTraceElement otherSte = new StackTraceElement("com.acme.Foo", "baz", "Foo.java", 34); + + @Test + public void nullStackTraceElementIsRejected() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> new StackTraceElementProxy(null)); + assertEquals("ste cannot be null", e.getMessage()); + } + + @Test + public void stringFormIsTheElementPrefixedWithAt() { + StackTraceElementProxy step = new StackTraceElementProxy(ste); + + assertEquals("at com.acme.Foo.bar(Foo.java:12)", step.getSTEAsString()); + assertSame(step.getSTEAsString(), step.getSTEAsString()); + assertEquals(step.getSTEAsString(), step.toString()); + } + + @Test + public void hashCodeIsTheElementHashCode() { + assertEquals(ste.hashCode(), new StackTraceElementProxy(ste).hashCode()); + } + + @Test + public void classPackagingDataCanBeSetOnlyOnce() { + StackTraceElementProxy step = new StackTraceElementProxy(ste); + ClassPackagingData first = new ClassPackagingData("foo.jar", "1.0"); + step.setClassPackagingData(first); + + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> step.setClassPackagingData(new ClassPackagingData("bar.jar", "2.0"))); + assertEquals("Packaging data has been already set", e.getMessage()); + assertSame(first, step.getClassPackagingData()); + } + + @Test + public void equalsIsReflexive() { + StackTraceElementProxy step = new StackTraceElementProxy(ste); + assertTrue(step.equals(step)); + } + + @Test + public void notEqualToNullOrAnotherClass() { + StackTraceElementProxy step = new StackTraceElementProxy(ste); + assertFalse(step.equals(null)); + assertFalse(step.equals(ste)); + } + + @Test + public void stackTraceElementIsCompared() { + assertNotEquals(new StackTraceElementProxy(ste), new StackTraceElementProxy(otherSte)); + } + + @Test + public void equalWithSameElementAndNoPackagingData() { + assertEquals(new StackTraceElementProxy(ste), new StackTraceElementProxy(ste)); + } + + @Test + public void classPackagingDataIsCompared() { + StackTraceElementProxy withoutCpd = new StackTraceElementProxy(ste); + StackTraceElementProxy withFoo = withCpd(new ClassPackagingData("foo.jar", "1.0")); + StackTraceElementProxy withOtherFoo = withCpd(new ClassPackagingData("foo.jar", "1.0")); + StackTraceElementProxy withBar = withCpd(new ClassPackagingData("bar.jar", "1.0")); + + assertNotEquals(withoutCpd, withFoo); + assertNotEquals(withFoo, withoutCpd); + assertNotEquals(withFoo, withBar); + assertEquals(withFoo, withOtherFoo); + } + + private StackTraceElementProxy withCpd(ClassPackagingData cpd) { + StackTraceElementProxy step = new StackTraceElementProxy(ste); + step.setClassPackagingData(cpd); + return step; + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/ThrowableProxyTest.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/ThrowableProxyTest.java index 92b38a3ae..fa737546f 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/spi/ThrowableProxyTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/ThrowableProxyTest.java @@ -16,7 +16,9 @@ package ch.qos.logback.classic.spi; import static junit.framework.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assume.assumeTrue; import static org.mockito.Mockito.doReturn; @@ -26,6 +28,7 @@ import java.io.PrintWriter; import java.io.StringWriter; +import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import ch.qos.logback.classic.util.TestHelper; @@ -234,6 +237,53 @@ public void suppressedThrowablesAreProxiedInOrderWithTheirCommonFrames() { assertEquals(expectedCommonFrames, suppressed[1].getCommonFrames()); } + @Test + public void keepsTheProxiedThrowable() { + Exception e = new Exception("kept"); + assertSame(e, new ThrowableProxy(e).getThrowable()); + } + + @Test + public void packagingDataCalculatorIsCreatedOnceAndReused() { + ThrowableProxy tp = new ThrowableProxy(new Exception("x")); + + PackagingDataCalculator pdc = tp.getPackagingDataCalculator(); + + assertNotNull(pdc); + assertSame(pdc, tp.getPackagingDataCalculator()); + } + + @Test + public void packagingDataIsCalculatedOnlyOnce() { + ThrowableProxy tp = new ThrowableProxy(new Exception("x")); + tp.calculatePackagingData(); + StackTraceElementProxy[] steps = tp.getStackTraceElementProxyArray(); + ClassPackagingData first = steps[0].getClassPackagingData(); + assertNotNull(first); + + // a second calculation would fail: packaging data can be set only once per frame + tp.calculatePackagingData(); + + assertSame(first, steps[0].getClassPackagingData()); + } + + @Test + public void proxyWithoutThrowableHasNoPackagingDataCalculator() throws Exception { + // getPackagingDataCalculator() assumes a proxy without throwable was deserialized; + // ThrowableProxy is no longer Serializable, so clear the field reflectively + ThrowableProxy tp = new ThrowableProxy(new Exception("x")); + Field throwableField = ThrowableProxy.class.getDeclaredField("throwable"); + throwableField.setAccessible(true); + throwableField.set(tp, null); + + assertNull(tp.getPackagingDataCalculator()); + tp.calculatePackagingData(); + + for (StackTraceElementProxy step : tp.getStackTraceElementProxyArray()) { + assertNull(step.getClassPackagingData()); + } + } + void someMethod() throws Exception { throw new Exception("someMethod"); } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/ThrowableProxyUtilTest.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/ThrowableProxyUtilTest.java new file mode 100644 index 000000000..1a9731494 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/ThrowableProxyUtilTest.java @@ -0,0 +1,156 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.spi; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import org.junit.Test; + +import ch.qos.logback.core.CoreConstants; + +public class ThrowableProxyUtilTest { + + private static final String LS = CoreConstants.LINE_SEPARATOR; + + private static final StackTraceElement A = ste("A"); + private static final StackTraceElement B = ste("B"); + private static final StackTraceElement C = ste("C"); + private static final StackTraceElement X = ste("X"); + + @Test + public void buildWithParentCountsFramesCommonWithTheParent() { + ThrowableProxy parentTP = new ThrowableProxy(throwableWithFrames("parent", A, B, C)); + Throwable nested = throwableWithFrames("nested", X, B, C); + ThrowableProxy nestedTP = new ThrowableProxy(throwableWithFrames("placeholder", A)); + + ThrowableProxyUtil.build(nestedTP, nested, parentTP); + + assertEquals(2, nestedTP.getCommonFrames()); + assertArrayEquals(steps(X, B, C), nestedTP.getStackTraceElementProxyArray()); + } + + @Test + public void buildWithoutParentMarksCommonFramesAsUnknown() { + Throwable nested = throwableWithFrames("nested", X, B); + ThrowableProxy nestedTP = new ThrowableProxy(throwableWithFrames("placeholder", A)); + + ThrowableProxyUtil.build(nestedTP, nested, null); + + assertEquals(-1, nestedTP.getCommonFrames()); + assertArrayEquals(steps(X, B), nestedTP.getStackTraceElementProxyArray()); + } + + @Test + public void noCommonFramesWhenEitherArrayIsMissing() { + assertEquals(0, ThrowableProxyUtil.findNumberOfCommonFrames(null, steps(A))); + assertEquals(0, ThrowableProxyUtil.findNumberOfCommonFrames(new StackTraceElement[] {A}, null)); + } + + @Test + public void commonFrameCountStopsWhenEitherArrayIsExhausted() { + assertEquals(2, ThrowableProxyUtil.findNumberOfCommonFrames(new StackTraceElement[] {A, B, C}, steps(B, C))); + assertEquals(2, ThrowableProxyUtil.findNumberOfCommonFrames(new StackTraceElement[] {B, C}, steps(A, B, C))); + assertEquals(0, ThrowableProxyUtil.findNumberOfCommonFrames(new StackTraceElement[] {A, B}, steps(A, C))); + } + + @Test + public void asStringToleratesProxyWithoutSuppressedArray() { + DummyThrowableProxy tp = new DummyThrowableProxy(); + tp.setClassName("com.acme.BoomException"); + tp.setMessage("boom"); + tp.setStackTraceElementProxyArray(steps(A)); + tp.setSuppressed(null); + + assertEquals("com.acme.BoomException: boom" + LS + "\tat A.method(A.java:1)" + LS, + ThrowableProxyUtil.asString(tp)); + } + + @Test + public void noPackagingDataIsAppendedForNullStep() { + StringBuilder sb = new StringBuilder("x"); + ThrowableProxyUtil.subjoinPackagingData(sb, null); + assertEquals("x", sb.toString()); + } + + @Test + public void noPackagingDataIsAppendedForStepWithoutPackagingData() { + StringBuilder sb = new StringBuilder("x"); + ThrowableProxyUtil.subjoinPackagingData(sb, new StackTraceElementProxy(A)); + assertEquals("x", sb.toString()); + } + + @Test + public void exactPackagingDataIsAppendedInBrackets() { + StackTraceElementProxy step = new StackTraceElementProxy(A); + step.setClassPackagingData(new ClassPackagingData("foo.jar", "1.0", true)); + + StringBuilder sb = new StringBuilder(); + ThrowableProxyUtil.subjoinPackagingData(sb, step); + + assertEquals(" [foo.jar:1.0]", sb.toString()); + } + + @Test + public void inexactPackagingDataIsAppendedWithTilde() { + StackTraceElementProxy step = new StackTraceElementProxy(A); + step.setClassPackagingData(new ClassPackagingData("foo.jar", "1.0", false)); + + StringBuilder sb = new StringBuilder(); + ThrowableProxyUtil.subjoinPackagingData(sb, step); + + assertEquals(" ~[foo.jar:1.0]", sb.toString()); + } + + @SuppressWarnings("deprecation") + @Test + public void deprecatedSubjoinSTEPArrayUsesTheRegularIndent() { + DummyThrowableProxy tp = new DummyThrowableProxy(); + tp.setStackTraceElementProxyArray(steps(A, B, C)); + tp.setCommonFramesCount(1); + + StringBuilder deprecated = new StringBuilder(); + ThrowableProxyUtil.subjoinSTEPArray(deprecated, tp); + + assertEquals("\tat A.method(A.java:1)" + LS + + "\tat B.method(B.java:1)" + LS + + "\t... 1 common frames omitted" + LS, deprecated.toString()); + StringBuilder current = new StringBuilder(); + ThrowableProxyUtil.subjoinSTEPArray(current, ThrowableProxyUtil.REGULAR_EXCEPTION_INDENT, tp); + assertEquals(current.toString(), deprecated.toString()); + } + + @Test + public void publicConstructorIsAvailable() { + // the class only has static members, but its implicit public constructor is API + assertNotNull(new ThrowableProxyUtil()); + } + + private static StackTraceElement ste(String className) { + return new StackTraceElement(className, "method", className + ".java", 1); + } + + private static StackTraceElementProxy[] steps(StackTraceElement... stes) { + return ThrowableProxyUtil.steArrayToStepArray(stes); + } + + private static Throwable throwableWithFrames(String message, StackTraceElement... frames) { + Throwable t = new Exception(message); + t.setStackTrace(frames); + return t; + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/TurboFilterListTest.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/TurboFilterListTest.java new file mode 100644 index 000000000..a742f6b4d --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/TurboFilterListTest.java @@ -0,0 +1,148 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.spi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; + +import java.util.Collections; +import java.util.List; + +import org.junit.Test; +import org.slf4j.Marker; +import org.slf4j.MarkerFactory; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.turbo.TurboFilter; +import ch.qos.logback.core.spi.FilterReply; + +public class TurboFilterListTest { + + private final LoggerContext loggerContext = new LoggerContext(); + private final Logger logger = loggerContext.getLogger("a.b"); + private final List markers = Collections.singletonList(MarkerFactory.getMarker("M")); + private final Object[] params = new Object[] {1}; + private final Throwable throwable = new Exception("t"); + private final TurboFilterList list = new TurboFilterList(); + + @Test + public void emptyListIsNeutral() { + assertEquals(FilterReply.NEUTRAL, decide(list)); + } + + @Test + public void singleFilterDecidesAndReceivesTheCallArguments() { + RecordingTurboFilter deny = new RecordingTurboFilter(FilterReply.DENY); + list.add(deny); + + assertEquals(FilterReply.DENY, decide(list)); + assertEquals(1, deny.calls); + assertSame(markers, deny.markers); + assertSame(logger, deny.logger); + assertSame(Level.INFO, deny.level); + assertEquals("format", deny.format); + assertSame(params, deny.params); + assertSame(throwable, deny.t); + } + + @Test + public void singleFilterRemovedConcurrentlyIsNeutral() { + // size() reports one filter, but it is removed before get(0) + TurboFilterList racyList = spy(new TurboFilterList()); + doReturn(1).when(racyList).size(); + + assertEquals(FilterReply.NEUTRAL, decide(racyList)); + } + + @Test + public void firstDenyWinsAndStopsTheChain() { + RecordingTurboFilter neutral = new RecordingTurboFilter(FilterReply.NEUTRAL); + RecordingTurboFilter deny = new RecordingTurboFilter(FilterReply.DENY); + RecordingTurboFilter accept = new RecordingTurboFilter(FilterReply.ACCEPT); + list.add(neutral); + list.add(deny); + list.add(accept); + + assertEquals(FilterReply.DENY, decide(list)); + assertEquals(1, neutral.calls); + assertEquals(1, deny.calls); + assertEquals(0, accept.calls); + assertSame(throwable, deny.t); + } + + @Test + public void firstAcceptWinsAndStopsTheChain() { + RecordingTurboFilter neutral = new RecordingTurboFilter(FilterReply.NEUTRAL); + RecordingTurboFilter accept = new RecordingTurboFilter(FilterReply.ACCEPT); + RecordingTurboFilter deny = new RecordingTurboFilter(FilterReply.DENY); + list.add(neutral); + list.add(accept); + list.add(deny); + + assertEquals(FilterReply.ACCEPT, decide(list)); + assertEquals(1, neutral.calls); + assertEquals(1, accept.calls); + assertEquals(0, deny.calls); + } + + @Test + public void allNeutralFiltersAreConsultedAndYieldNeutral() { + RecordingTurboFilter first = new RecordingTurboFilter(FilterReply.NEUTRAL); + RecordingTurboFilter second = new RecordingTurboFilter(FilterReply.NEUTRAL); + list.add(first); + list.add(second); + + assertEquals(FilterReply.NEUTRAL, decide(list)); + assertEquals(1, first.calls); + assertEquals(1, second.calls); + } + + private FilterReply decide(TurboFilterList tfl) { + return tfl.getTurboFilterChainDecision(markers, logger, Level.INFO, "format", params, throwable); + } + + static class RecordingTurboFilter extends TurboFilter { + final FilterReply reply; + int calls; + List markers; + Logger logger; + Level level; + String format; + Object[] params; + Throwable t; + + RecordingTurboFilter(FilterReply reply) { + this.reply = reply; + } + + @Override + public FilterReply decide(List markers, Logger logger, Level level, String format, + Object[] params, Throwable t) { + calls++; + this.markers = markers; + this.logger = logger; + this.level = level; + this.format = format; + this.params = params; + this.t = t; + return reply; + } + } +} From 524a3de5b042b67729d310738e68c77fa83ffd7b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:25:05 +0000 Subject: [PATCH 27/79] test: strengthen classic spi tests against surviving mutants - PackagingDataCalculatorTest: pin the idx==0 boundary of the code location parser, the "na" version of a class without Implementation-Version, and that the expected lookups (no or unaware context class loader) report nothing on System.err. The stderr capture now records only the calculating thread's output, so stray output from other threads cannot flake the assertions. - StackTraceElementProxyTest: an instance of a subclass is not equal. - LoggerRemoteViewTest: load a copy of LoggerRemoteView with its assertion status set explicitly, so both outcomes of the assert are tested and covered whatever the JVM's -ea/-da flags are, instead of skipping one test via Assume. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../classic/spi/LoggerRemoteViewTest.java | 99 ++++++++++++++++--- .../spi/PackagingDataCalculatorTest.java | 70 +++++++++++-- .../spi/StackTraceElementProxyTest.java | 8 ++ 3 files changed, 158 insertions(+), 19 deletions(-) diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggerRemoteViewTest.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggerRemoteViewTest.java index 17d8c2b58..b2688d771 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggerRemoteViewTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/LoggerRemoteViewTest.java @@ -17,11 +17,17 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; -import static org.junit.Assume.assumeFalse; -import static org.junit.Assume.assumeTrue; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; import org.junit.Test; @@ -42,23 +48,48 @@ public void keepsTheNameAndTheRemoteViewOfTheContext() { } @Test - public void contextWithoutRemoteViewFailsTheAssertionWhenAssertionsAreEnabled() { - // Gradle runs unit tests with assertions enabled (-ea) - assumeTrue(LoggerRemoteView.class.desiredAssertionStatus()); + public void contextWithRemoteViewPassesTheAssertionWhenAssertionsAreEnabled() throws Exception { + Constructor constructor = loggerRemoteViewConstructor(true); + LoggerContext lc = new LoggerContext(); + + Object view = constructor.newInstance("a.b.C", lc); - AssertionError e = assertThrows(AssertionError.class, - () -> new LoggerRemoteView("a.b.C", new ContextWithoutRemoteView())); - assertNull(e.getMessage()); + assertEquals("a.b.C", view.getClass().getMethod("getName").invoke(view)); + assertSame(lc.getLoggerContextRemoteView(), view.getClass().getMethod("getLoggerContextView").invoke(view)); } @Test - public void contextWithoutRemoteViewYieldsNullViewWhenAssertionsAreDisabled() { - assumeFalse(LoggerRemoteView.class.desiredAssertionStatus()); + public void contextWithoutRemoteViewFailsTheAssertionWhenAssertionsAreEnabled() throws Exception { + Constructor constructor = loggerRemoteViewConstructor(true); - LoggerRemoteView view = new LoggerRemoteView("a.b.C", new ContextWithoutRemoteView()); + InvocationTargetException e = assertThrows(InvocationTargetException.class, + () -> constructor.newInstance("a.b.C", new ContextWithoutRemoteView())); + assertTrue(String.valueOf(e.getCause()), e.getCause() instanceof AssertionError); + assertNull(e.getCause().getMessage()); + } - assertEquals("a.b.C", view.getName()); - assertNull(view.getLoggerContextView()); + @Test + public void contextWithoutRemoteViewYieldsNullViewWhenAssertionsAreDisabled() throws Exception { + Constructor constructor = loggerRemoteViewConstructor(false); + + Object view = constructor.newInstance("a.b.C", new ContextWithoutRemoteView()); + + assertEquals("a.b.C", view.getClass().getMethod("getName").invoke(view)); + assertNull(view.getClass().getMethod("getLoggerContextView").invoke(view)); + } + + /** + * Returns the constructor of a fresh copy of {@link LoggerRemoteView} whose assert + * statement is enabled or disabled as requested, whatever the JVM's -ea/-da flags are. + */ + private static Constructor loggerRemoteViewConstructor(boolean assertionsEnabled) throws Exception { + AssertionStatusClassLoader loader = new AssertionStatusClassLoader(); + // must precede the loading of the class: its assertion status is fixed when it is initialized + loader.setClassAssertionStatus(LoggerRemoteView.class.getName(), assertionsEnabled); + Class copy = loader.loadClass(LoggerRemoteView.class.getName()); + assertNotSame(LoggerRemoteView.class, copy); + assertEquals(assertionsEnabled, copy.desiredAssertionStatus()); + return copy.getConstructor(String.class, LoggerContext.class); } static class ContextWithoutRemoteView extends LoggerContext { @@ -67,4 +98,46 @@ public LoggerContextVO getLoggerContextRemoteView() { return null; } } + + /** + * Defines its own copy of {@link LoggerRemoteView}, from the same class file and code + * source, and delegates every other class to the class loader of this test. + */ + static class AssertionStatusClassLoader extends ClassLoader { + + AssertionStatusClassLoader() { + super(LoggerRemoteViewTest.class.getClassLoader()); + } + + @Override + protected synchronized Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!LoggerRemoteView.class.getName().equals(name)) { + return super.loadClass(name, resolve); + } + Class copy = findLoadedClass(name); + if (copy == null) { + byte[] bytes = readClassBytes(name); + copy = defineClass(name, bytes, 0, bytes.length, LoggerRemoteView.class.getProtectionDomain()); + } + return copy; + } + + private byte[] readClassBytes(String name) throws ClassNotFoundException { + String resource = name.replace('.', '/') + ".class"; + try (InputStream in = getParent().getResourceAsStream(resource)) { + if (in == null) { + throw new ClassNotFoundException(name); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int n; + while ((n = in.read(buffer)) != -1) { + out.write(buffer, 0, n); + } + return out.toByteArray(); + } catch (IOException e) { + throw new ClassNotFoundException(name, e); + } + } + } } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/PackagingDataCalculatorTest.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/PackagingDataCalculatorTest.java index 1361a314b..65c7c6d6d 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/spi/PackagingDataCalculatorTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/PackagingDataCalculatorTest.java @@ -20,6 +20,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @@ -27,6 +28,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.io.PrintStream; import java.io.UnsupportedEncodingException; import java.lang.reflect.Method; @@ -193,13 +195,15 @@ public void framesOfTheSameClassShareCachedPackagingData() { } @Test - public void classIsResolvedWithClassForNameWhenThereIsNoContextClassLoader() { + public void classIsResolvedWithClassForNameWhenThereIsNoContextClassLoader() throws Exception { Thread.currentThread().setContextClassLoader(null); StackTraceElementProxy step = step(Test.class.getName()); - new PackagingDataCalculator().calculate(proxyOf(step)); + String err = calculateCapturingStdErr(proxyOf(step)); assertPackagingDataOf(Test.class, step.getClassPackagingData()); + // a missing class loader is expected, not an error to report + assertEquals("", err); } @Test @@ -209,9 +213,11 @@ public void classUnknownToTheContextClassLoaderIsResolvedWithClassForName() thro Thread.currentThread().setContextClassLoader(bootstrapOnly); StackTraceElementProxy step = step(Test.class.getName()); - new PackagingDataCalculator().calculate(proxyOf(step)); + String err = calculateCapturingStdErr(proxyOf(step)); assertPackagingDataOf(Test.class, step.getClassPackagingData()); + // the ClassNotFoundException of the context class loader is expected, not reported + assertEquals("", err); } finally { bootstrapOnly.close(); } @@ -319,6 +325,34 @@ public void folderNameIsExtractedFromFolderLocation() throws Exception { assertEquals("classes/", step.getClassPackagingData().getCodeLocation()); } + @Test + public void leadingSeparatorAloneDoesNotDelimitACodeLocation() throws Exception { + // a separator at index 0 is not taken as the start of a file name + useSubjectLoader(codeSourceAt("/subject-1.0.jar")); + StackTraceElementProxy step = step(PackagingDataCalculatorSubject.class.getName()); + + new PackagingDataCalculator().calculate(proxyOf(step)); + + assertNull(step.getClassPackagingData().getCodeLocation()); + } + + @Test + public void classWithoutImplementationVersionHasNaVersion() throws Exception { + SubjectClassLoader loader = useSubjectLoader(codeSourceAt("file:/opt/lib/subject-1.0.jar")); + Class subject = loader.loadClass(PackagingDataCalculatorSubject.class.getName()); + // precondition: the subject's package is defined without manifest attributes + assertNotNull(subject.getPackage()); + assertNull(subject.getPackage().getImplementationVersion()); + StackTraceElementProxy step = step(PackagingDataCalculatorSubject.class.getName()); + + new PackagingDataCalculator().calculate(proxyOf(step)); + + ClassPackagingData cpd = step.getClassPackagingData(); + assertEquals("na", cpd.getVersion()); + assertEquals("subject-1.0.jar", cpd.getCodeLocation()); + assertFalse(cpd.isExact()); + } + @Test public void failureWhileReadingTheCodeLocationIsSwallowed() throws Exception { LocationHandler handler = new LocationHandler("file:/opt/lib/subject-1.0.jar"); @@ -360,11 +394,35 @@ private static DummyThrowableProxy proxyOf(StackTraceElementProxy... steps) { return tp; } + /** + * Runs the calculation and returns what it wrote to System.err. Only the output of + * this thread is captured; other threads' output still goes to the original stream. + */ private static String calculateCapturingStdErr(IThrowableProxy tp) throws UnsupportedEncodingException { - ByteArrayOutputStream err = new ByteArrayOutputStream(); - PrintStream originalErr = System.err; + final ByteArrayOutputStream err = new ByteArrayOutputStream(); + final PrintStream originalErr = System.err; + final Thread calculatingThread = Thread.currentThread(); + OutputStream thisThreadOnly = new OutputStream() { + @Override + public void write(int b) { + if (Thread.currentThread() == calculatingThread) { + err.write(b); + } else { + originalErr.write(b); + } + } + + @Override + public void write(byte[] b, int off, int len) { + if (Thread.currentThread() == calculatingThread) { + err.write(b, off, len); + } else { + originalErr.write(b, off, len); + } + } + }; try { - System.setErr(new PrintStream(err, true, "UTF-8")); + System.setErr(new PrintStream(thisThreadOnly, true, "UTF-8")); new PackagingDataCalculator().calculate(tp); } finally { System.setErr(originalErr); diff --git a/logback-android/src/test/java/ch/qos/logback/classic/spi/StackTraceElementProxyTest.java b/logback-android/src/test/java/ch/qos/logback/classic/spi/StackTraceElementProxyTest.java index 36957845e..1d4ab8b0e 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/spi/StackTraceElementProxyTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/spi/StackTraceElementProxyTest.java @@ -75,6 +75,14 @@ public void notEqualToNullOrAnotherClass() { assertFalse(step.equals(ste)); } + @Test + public void notEqualToInstanceOfASubclass() { + StackTraceElementProxy subclassInstance = new StackTraceElementProxy(ste) { + private static final long serialVersionUID = 1L; + }; + assertFalse(new StackTraceElementProxy(ste).equals(subclassInstance)); + } + @Test public void stackTraceElementIsCompared() { assertNotEquals(new StackTraceElementProxy(ste), new StackTraceElementProxy(otherSte)); From 98933d3e4bfb047aef876bc3b708eeca12d4ab69 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:57:25 +0000 Subject: [PATCH 28/79] refactor: drop impossible null check on ReconfigureOnChangeTask fallback events fallbackConfiguration() gets its events from removeIncludeEvents(), a private method that always returns a new (possibly empty) ArrayList, so the "failsafeEvents == null" operand can never be true and its branch can never be covered. Only the isEmpty() check is kept; behavior is unchanged for every input. ReconfigureOnChangeTaskJvmTest drives run() synchronously on the plain JVM and pins both outcomes of that check: an unreadable main XML file without a registered safe configuration warns "No previous configuration to fall back on.", and with one the safe events are replayed (root level restored) and registered again. It also pins the other run() paths the Robolectric ReconfigureOnChangeTaskTest leaves uncovered: a missing, null or empty watch list, a Groovy main URL, a main URL that is neither XML nor Groovy with no listeners, and a safe configuration that itself fails (reported as an error with its exception). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../joran/ReconfigureOnChangeTask.java | 2 +- .../joran/ReconfigureOnChangeTaskJvmTest.java | 341 ++++++++++++++++++ 2 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/joran/ReconfigureOnChangeTaskJvmTest.java diff --git a/logback-android/src/main/java/ch/qos/logback/classic/joran/ReconfigureOnChangeTask.java b/logback-android/src/main/java/ch/qos/logback/classic/joran/ReconfigureOnChangeTask.java index 18d9eeca1..9437c0a27 100644 --- a/logback-android/src/main/java/ch/qos/logback/classic/joran/ReconfigureOnChangeTask.java +++ b/logback-android/src/main/java/ch/qos/logback/classic/joran/ReconfigureOnChangeTask.java @@ -150,7 +150,7 @@ private void fallbackConfiguration(LoggerContext lc, List eventList) { ConfigurationWatchList oldCWL = ConfigurationWatchListUtil.getConfigurationWatchList(lc); ConfigurationWatchList newCWL = oldCWL.buildClone(); - if (failsafeEvents == null || failsafeEvents.isEmpty()) { + if (failsafeEvents.isEmpty()) { addWarn("No previous configuration to fall back on."); } else { addWarn(FALLING_BACK_TO_SAFE_CONFIGURATION); diff --git a/logback-android/src/test/java/ch/qos/logback/classic/joran/ReconfigureOnChangeTaskJvmTest.java b/logback-android/src/test/java/ch/qos/logback/classic/joran/ReconfigureOnChangeTaskJvmTest.java new file mode 100644 index 000000000..353fa0c00 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/joran/ReconfigureOnChangeTaskJvmTest.java @@ -0,0 +1,341 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.joran; + +import static ch.qos.logback.classic.joran.ReconfigureOnChangeTask.DETECTED_CHANGE_IN_CONFIGURATION_FILES; +import static ch.qos.logback.classic.joran.ReconfigureOnChangeTask.FALLING_BACK_TO_SAFE_CONFIGURATION; +import static ch.qos.logback.classic.joran.ReconfigureOnChangeTask.RE_REGISTERING_PREVIOUS_SAFE_CONFIGURATION; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.regex.Pattern; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedConstruction; +import org.xml.sax.helpers.AttributesImpl; +import org.xml.sax.helpers.LocatorImpl; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.joran.event.SaxEvent; +import ch.qos.logback.core.joran.event.SaxEventRecorder; +import ch.qos.logback.core.joran.spi.ConfigurationWatchList; +import ch.qos.logback.core.joran.spi.JoranException; +import ch.qos.logback.core.joran.util.ConfigurationWatchListUtil; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; + +/** + * Plain-JVM tests of {@link ReconfigureOnChangeTask#run()} that drive the task + * synchronously, without a scheduler, a real XML parser or file-modification + * timing. The scheduled, end-to-end scenarios live in the Robolectric-run + * {@link ReconfigureOnChangeTaskTest}. + */ +public class ReconfigureOnChangeTaskJvmTest { + + static final String MARKER_KEY = "rocTaskMarker"; + static final String NO_PREVIOUS_CONFIGURATION = "No previous configuration to fall back on."; + static final String SAFE_CONFIGURATION_FAILED = "Unexpected exception thrown by a configuration considered safe."; + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + LoggerContext loggerContext = new LoggerContext(); + StatusChecker statusChecker = new StatusChecker(loggerContext); + ReconfigureOnChangeTask task = new ReconfigureOnChangeTask(); + List notifications = new ArrayList(); + + @Before + public void setUp() { + loggerContext.setName("rocTaskJvm"); + // reset() clears the context properties: the marker tells whether a reconfiguration took place + loggerContext.putProperty(MARKER_KEY, "present"); + task.setContext(loggerContext); + } + + @Test + public void runWithoutWatchListWarnsAndNotifiesEveryListenerOnlyOfEntry() { + task.addListener(new RecordingListener("first")); + task.addListener(new RecordingListener("second")); + + task.run(); + + assertEquals(Arrays.asList("first:entered", "second:entered"), notifications); + statusChecker.assertContainsMatch(Status.WARN, Pattern.quote("Empty ConfigurationWatchList in context")); + statusChecker.assertNoMatch(Pattern.quote(DETECTED_CHANGE_IN_CONFIGURATION_FILES)); + assertContextWasNotReset(); + } + + @Test + public void runWithNullWatchFileListDisablesScanningWithoutCheckingForChanges() throws IOException { + StubWatchList watchList = new StubWatchList(xmlUrl("main.xml"), null, true); + ConfigurationWatchListUtil.registerConfigurationWatchList(loggerContext, watchList); + + task.run(); + + statusChecker.assertContainsMatch(Status.INFO, Pattern.quote("Empty watch file list. Disabling ")); + assertEquals(0, watchList.changeDetectedCalls); + statusChecker.assertNoMatch(Pattern.quote(DETECTED_CHANGE_IN_CONFIGURATION_FILES)); + assertContextWasNotReset(); + } + + @Test + public void runWithEmptyWatchFileListDisablesScanning() { + ConfigurationWatchList watchList = new ConfigurationWatchList(); + watchList.setContext(loggerContext); + ConfigurationWatchListUtil.registerConfigurationWatchList(loggerContext, watchList); + task.addListener(new RecordingListener("l")); + + task.run(); + + statusChecker.assertContainsMatch(Status.INFO, Pattern.quote("Empty watch file list. Disabling ")); + statusChecker.assertNoMatch(Pattern.quote(DETECTED_CHANGE_IN_CONFIGURATION_FILES)); + assertEquals(Collections.singletonList("l:entered"), notifications); + assertContextWasNotReset(); + } + + @Test + public void runAbortsReconfigurationFromGroovyFileWithAnError() throws IOException { + URL groovyUrl = tmp.newFile("logback.groovy").toURI().toURL(); + registerChangedWatchList(groovyUrl); + task.addListener(new RecordingListener("l")); + + task.run(); + + statusChecker.assertContainsMatch(Status.INFO, Pattern.quote(DETECTED_CHANGE_IN_CONFIGURATION_FILES)); + statusChecker.assertContainsMatch(Status.INFO, + Pattern.quote(CoreConstants.RESET_MSG_PREFIX + "named [rocTaskJvm]")); + statusChecker.assertContainsMatch(Status.ERROR, + Pattern.quote("Groovy classes are not available on the class path. ABORTING INITIALIZATION.")); + assertEquals(Arrays.asList("l:entered", "l:changeDetected", "l:done"), notifications); + assertContextWasNotReset(); + } + + @Test + public void runWithoutListenersIgnoresChangedFileThatIsNeitherXmlNorGroovy() throws IOException { + URL propertiesUrl = tmp.newFile("logback.properties").toURI().toURL(); + registerChangedWatchList(propertiesUrl); + + task.run(); + + statusChecker.assertContainsMatch(Status.INFO, Pattern.quote(DETECTED_CHANGE_IN_CONFIGURATION_FILES)); + statusChecker.assertContainsMatch(Status.INFO, + Pattern.quote(CoreConstants.RESET_MSG_PREFIX + "named [rocTaskJvm]")); + statusChecker.assertIsErrorFree(); + assertContextWasNotReset(); + } + + @Test + public void unreadableMainXmlWithoutSafeConfigurationWarnsThatThereIsNothingToFallBackOn() throws IOException { + URL missingXml = xmlUrl("missing.xml"); + registerChangedWatchList(missingXml); + task.addListener(new RecordingListener("l")); + + task.run(); + + assertNull("the context should have been reset", loggerContext.getProperty(MARKER_KEY)); + statusChecker.assertContainsMatch(Status.ERROR, Pattern.quote("Could not open URL [" + missingXml + "].")); + statusChecker.assertContainsMatch(Status.WARN, Pattern.quote(NO_PREVIOUS_CONFIGURATION)); + statusChecker.assertNoMatch(Pattern.quote(FALLING_BACK_TO_SAFE_CONFIGURATION)); + assertEquals(Arrays.asList("l:entered", "l:changeDetected", "l:done"), notifications); + } + + @Test + public void unreadableMainXmlFallsBackToSafeConfiguration() throws IOException { + URL missingXml = xmlUrl("missing.xml"); + registerChangedWatchList(missingXml); + List safeEvents = configurationWithRootLevel(Level.WARN); + JoranConfigurator safeConfigurator = new JoranConfigurator(); + safeConfigurator.setContext(loggerContext); + safeConfigurator.registerSafeConfiguration(safeEvents); + assertEquals(Level.DEBUG, rootLevel()); + + task.run(); + + assertNull("the context should have been reset", loggerContext.getProperty(MARKER_KEY)); + statusChecker.assertContainsMatch(Status.ERROR, Pattern.quote("Could not open URL [" + missingXml + "].")); + statusChecker.assertContainsMatch(Status.WARN, Pattern.quote(FALLING_BACK_TO_SAFE_CONFIGURATION)); + statusChecker.assertContainsMatch(Status.INFO, Pattern.quote(RE_REGISTERING_PREVIOUS_SAFE_CONFIGURATION)); + statusChecker.assertNoMatch(Pattern.quote(NO_PREVIOUS_CONFIGURATION)); + // the safe configuration was replayed and registered once more + assertEquals(Level.WARN, rootLevel()); + assertSame(safeEvents, loggerContext.getObject(CoreConstants.SAFE_JORAN_CONFIGURATION)); + // the watch list of the failed attempt is kept (as a copy) so that scanning goes on + ConfigurationWatchList watchList = ConfigurationWatchListUtil.getConfigurationWatchList(loggerContext); + assertNotNull(watchList); + assertEquals(missingXml, watchList.getMainURL()); + } + + @Test + public void failingSafeConfigurationIsReportedAsAnError() throws Exception { + final URL mainXml = xmlUrl("main.xml"); + registerChangedWatchList(mainXml); + final List safeEvents = configurationWithRootLevel(Level.WARN); + final JoranException mainFailure = new JoranException("main configuration is broken"); + final JoranException safeFailure = new JoranException("safe configuration is broken"); + + try (MockedConstruction configurators = mockConstruction(JoranConfigurator.class, + (mock, ctx) -> { + if (ctx.getCount() == 1) { + // the configurator of the main file: it registers the file and then fails + when(mock.recallSafeConfiguration()).thenReturn(safeEvents); + doAnswer(invocation -> { + JoranConfigurator.informContextOfURLUsedForConfiguration(loggerContext, mainXml); + throw mainFailure; + }).when(mock).doConfigure(mainXml); + } else { + // the configurator of the safe fallback events + doThrow(safeFailure).when(mock).doConfigure(anyList()); + } + })) { + + task.run(); + + assertEquals(2, configurators.constructed().size()); + JoranConfigurator fallbackConfigurator = configurators.constructed().get(1); + verify(fallbackConfigurator).setContext(loggerContext); + verify(fallbackConfigurator).doConfigure(safeEvents); + verify(fallbackConfigurator, never()).registerSafeConfiguration(any()); + } + + statusChecker.assertContainsMatch(Status.WARN, Pattern.quote(FALLING_BACK_TO_SAFE_CONFIGURATION)); + Status error = findStatus(SAFE_CONFIGURATION_FAILED); + assertEquals(Status.ERROR, error.getLevel()); + assertSame(safeFailure, error.getThrowable()); + statusChecker.assertNoMatch(Pattern.quote(RE_REGISTERING_PREVIOUS_SAFE_CONFIGURATION)); + // the fallback registered a copy of the watch list before replaying the safe events + ConfigurationWatchList watchList = ConfigurationWatchListUtil.getConfigurationWatchList(loggerContext); + assertEquals(mainXml, watchList.getMainURL()); + } + + private URL xmlUrl(String name) throws IOException { + return new File(tmp.getRoot(), name).toURI().toURL(); + } + + private StubWatchList registerChangedWatchList(URL mainUrl) { + StubWatchList watchList = new StubWatchList(mainUrl, + Collections.singletonList(new File(mainUrl.getFile())), true); + ConfigurationWatchListUtil.registerConfigurationWatchList(loggerContext, watchList); + return watchList; + } + + private void assertContextWasNotReset() { + assertEquals("present", loggerContext.getProperty(MARKER_KEY)); + } + + private Level rootLevel() { + return loggerContext.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME).getLevel(); + } + + private Status findStatus(String message) { + for (Status s : loggerContext.getStatusManager().getCopyOfStatusList()) { + if (message.equals(s.getMessage())) { + return s; + } + } + throw new AssertionError("no status [" + message + "]"); + } + + /** + * The SAX events of {@code }, + * recorded through the public ContentHandler API, so no XML parser is needed. + */ + private List configurationWithRootLevel(Level level) { + SaxEventRecorder recorder = new SaxEventRecorder(loggerContext); + recorder.setDocumentLocator(new LocatorImpl()); + recorder.startElement("", "configuration", "configuration", new AttributesImpl()); + AttributesImpl rootAttributes = new AttributesImpl(); + rootAttributes.addAttribute("", "level", "level", "CDATA", level.toString()); + recorder.startElement("", "root", "root", rootAttributes); + recorder.endElement("", "root", "root"); + recorder.endElement("", "configuration", "configuration"); + return recorder.getSaxEventList(); + } + + /** A watch list with a fixed main URL, file list and change status. */ + static class StubWatchList extends ConfigurationWatchList { + final URL mainUrl; + final List files; + final boolean changed; + int changeDetectedCalls; + + StubWatchList(URL mainUrl, List files, boolean changed) { + this.mainUrl = mainUrl; + this.files = files; + this.changed = changed; + } + + @Override + public URL getMainURL() { + return mainUrl; + } + + @Override + public List getCopyOfFileWatchList() { + return files == null ? null : new ArrayList(files); + } + + @Override + public boolean changeDetected() { + changeDetectedCalls++; + return changed; + } + } + + class RecordingListener extends ReconfigureOnChangeTaskListener { + final String name; + + RecordingListener(String name) { + this.name = name; + } + + @Override + void enteredRunMethod() { + notifications.add(name + ":entered"); + } + + @Override + void changeDetected() { + notifications.add(name + ":changeDetected"); + } + + @Override + void doneReconfiguring() { + notifications.add(name + ":done"); + } + } +} From 9f37e5514e75505b9226f94903fd2c3331f552f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:57:33 +0000 Subject: [PATCH 29/79] test: cover ReconfigureOnChangeFilter and the classic turbo filters ReconfigureOnChangeFilter had no running test (its only test is ignored). The new plain-JVM ReconfigureOnChangeFilterTest pins start() with no, URL-less and complete watch lists; decide() being inert when stopped, looking for changes only once every mask+1 calls, and detaching exactly one ReconfiguringThread (then disabling further checks) on a change; the mask growing, capping at 0xFFFF and shrinking with the time between checks; changeDetected() honouring nextCheck; and ReconfiguringThread skipping without a main URL, ignoring non-XML URLs, reconfiguring from the main XML, and falling back to the safe configuration on parsing errors or an unreadable file (or warning that there is none, or reporting a failing safe configuration). A mock executor captures the submitted task and Mockito construction mocks stand in for the XML parser, which the plain JVM lacks. Also pinned: DynamicThresholdFilter property defaults, start without a key, duplicate MDC values and threshold selection; DuplicateMessageFilter getters and stop() discarding the message cache; LRUMessageCache rejecting a size below 1 and clear(); MarkerFilter mismatching markers and a null marker name; MatchingFilter onMatch/onMismatch parsing; and TurboFilter's name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../turbo/DuplicateMessageFilterTest.java | 40 ++ .../turbo/DynamicThresholdFilterTest.java | 160 ++++++ .../classic/turbo/LRUMessageCacheTest.java | 29 + .../classic/turbo/MarkerFilterTest.java | 45 ++ .../classic/turbo/MatchingFilterTest.java | 94 ++++ .../turbo/ReconfigureOnChangeFilterTest.java | 521 ++++++++++++++++++ .../classic/turbo/TurboFilterTest.java | 48 ++ 7 files changed, 937 insertions(+) create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/turbo/DynamicThresholdFilterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/turbo/MatchingFilterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/turbo/ReconfigureOnChangeFilterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/turbo/TurboFilterTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/classic/turbo/DuplicateMessageFilterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/turbo/DuplicateMessageFilterTest.java index f4eb03d2a..4f30d2416 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/turbo/DuplicateMessageFilterTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/turbo/DuplicateMessageFilterTest.java @@ -17,6 +17,8 @@ import static junit.framework.Assert.*; +import java.lang.reflect.Field; + import org.junit.Test; import ch.qos.logback.core.spi.FilterReply; @@ -88,4 +90,42 @@ public void nullFormat() { null)); } + @Test + public void allowedRepetitionsAndCacheSizeHaveDefaultsAndCanBeSet() { + DuplicateMessageFilter dmf = new DuplicateMessageFilter(); + assertEquals(5, dmf.getAllowedRepetitions()); + assertEquals(DuplicateMessageFilter.DEFAULT_ALLOWED_REPETITIONS, dmf.getAllowedRepetitions()); + assertEquals(100, dmf.getCacheSize()); + assertEquals(DuplicateMessageFilter.DEFAULT_CACHE_SIZE, dmf.getCacheSize()); + + dmf.setAllowedRepetitions(3); + dmf.setCacheSize(7); + + assertEquals(3, dmf.getAllowedRepetitions()); + assertEquals(7, dmf.getCacheSize()); + } + + @Test + public void stopClearsAndDropsTheMessageCache() throws Exception { + DuplicateMessageFilter dmf = new DuplicateMessageFilter(); + dmf.setAllowedRepetitions(0); + dmf.start(); + assertEquals(FilterReply.NEUTRAL, dmf.decide(null, null, null, "x", null, null)); + assertEquals(FilterReply.DENY, dmf.decide(null, null, null, "x", null, null)); + Field msgCacheField = DuplicateMessageFilter.class.getDeclaredField("msgCache"); + msgCacheField.setAccessible(true); + LRUMessageCache msgCache = (LRUMessageCache) msgCacheField.get(dmf); + assertFalse(msgCache.isEmpty()); + + dmf.stop(); + + assertFalse(dmf.isStarted()); + assertTrue(msgCache.isEmpty()); + assertNull(msgCacheField.get(dmf)); + + // a restarted filter has forgotten the messages seen before + dmf.start(); + assertEquals(FilterReply.NEUTRAL, dmf.decide(null, null, null, "x", null, null)); + } + } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/turbo/DynamicThresholdFilterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/turbo/DynamicThresholdFilterTest.java new file mode 100644 index 000000000..a38ac720d --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/turbo/DynamicThresholdFilterTest.java @@ -0,0 +1,160 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.turbo; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.regex.Pattern; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.MDC; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.spi.FilterReply; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; +import ch.qos.logback.core.testUtil.RandomUtil; + +public class DynamicThresholdFilterTest { + + int diff = RandomUtil.getPositiveInt(); + String key = "userId" + diff; + + ContextBase context = new ContextBase(); + StatusChecker statusChecker = new StatusChecker(context); + DynamicThresholdFilter filter = new DynamicThresholdFilter(); + + @Before + public void setUp() { + filter.setContext(context); + MDC.remove(key); + } + + @After + public void tearDown() { + MDC.remove(key); + } + + @Test + public void propertiesHaveDefaultsAndCanBeSet() { + assertNull(filter.getKey()); + assertEquals(Level.ERROR, filter.getDefaultThreshold()); + assertEquals(FilterReply.NEUTRAL, filter.getOnHigherOrEqual()); + assertEquals(FilterReply.DENY, filter.getOnLower()); + + filter.setKey(key); + filter.setDefaultThreshold(Level.WARN); + filter.setOnHigherOrEqual(FilterReply.ACCEPT); + filter.setOnLower(FilterReply.NEUTRAL); + + assertEquals(key, filter.getKey()); + assertEquals(Level.WARN, filter.getDefaultThreshold()); + assertEquals(FilterReply.ACCEPT, filter.getOnHigherOrEqual()); + assertEquals(FilterReply.NEUTRAL, filter.getOnLower()); + } + + @Test + public void startWithoutKeyReportsAnErrorButStillStarts() { + filter.start(); + + assertTrue(filter.isStarted()); + statusChecker.assertContainsMatch(Status.ERROR, Pattern.quote("No key name was specified")); + } + + @Test + public void startWithKeyReportsNoError() { + filter.setKey(key); + filter.start(); + + assertTrue(filter.isStarted()); + statusChecker.assertIsErrorFree(); + } + + @Test + public void decideIsNeutralWhenNotStarted() { + filter.setKey(key); + filter.setOnHigherOrEqual(FilterReply.ACCEPT); + filter.addMDCValueLevelPair(pair("user1", Level.DEBUG)); + MDC.put(key, "user1"); + + assertEquals(FilterReply.NEUTRAL, decide(Level.ERROR)); + assertEquals(FilterReply.NEUTRAL, decide(Level.TRACE)); + } + + @Test + public void decideAppliesTheDefaultThresholdWhenTheKeyIsNotInMdc() { + startWithUser1AtDebug(); + + assertEquals(FilterReply.DENY, decide(Level.WARN)); + assertEquals(FilterReply.ACCEPT, decide(Level.ERROR)); + } + + @Test + public void decideAppliesTheDefaultThresholdToAnUnmappedMdcValue() { + startWithUser1AtDebug(); + MDC.put(key, "user2"); + + assertEquals(FilterReply.DENY, decide(Level.WARN)); + assertEquals(FilterReply.ACCEPT, decide(Level.ERROR)); + } + + @Test + public void decideAppliesTheLevelMappedToTheMdcValue() { + startWithUser1AtDebug(); + MDC.put(key, "user1"); + + assertEquals(FilterReply.DENY, decide(Level.TRACE)); + assertEquals(FilterReply.ACCEPT, decide(Level.DEBUG)); + assertEquals(FilterReply.ACCEPT, decide(Level.INFO)); + } + + @Test + public void duplicateMdcValueIsReportedAndTheFirstLevelIsKept() { + filter.addMDCValueLevelPair(pair("user1", Level.DEBUG)); + filter.addMDCValueLevelPair(pair("user1", Level.ERROR)); + + statusChecker.assertContainsMatch(Status.ERROR, Pattern.quote("user1 has been already set")); + filter.setKey(key); + filter.setOnHigherOrEqual(FilterReply.ACCEPT); + filter.start(); + MDC.put(key, "user1"); + assertEquals(FilterReply.ACCEPT, decide(Level.DEBUG)); + } + + private void startWithUser1AtDebug() { + filter.setKey(key); + filter.setOnHigherOrEqual(FilterReply.ACCEPT); + filter.setOnLower(FilterReply.DENY); + filter.addMDCValueLevelPair(pair("user1", Level.DEBUG)); + filter.start(); + } + + private FilterReply decide(Level level) { + return filter.decide(null, null, level, "msg", null, null); + } + + private static MDCValueLevelPair pair(String value, Level level) { + MDCValueLevelPair pair = new MDCValueLevelPair(); + pair.setValue(value); + pair.setLevel(level); + return pair; + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/turbo/LRUMessageCacheTest.java b/logback-android/src/test/java/ch/qos/logback/classic/turbo/LRUMessageCacheTest.java index 380a5c267..7a5d634e2 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/turbo/LRUMessageCacheTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/turbo/LRUMessageCacheTest.java @@ -15,6 +15,8 @@ */ package ch.qos.logback.classic.turbo; +import static org.junit.Assert.assertThrows; + import junit.framework.Assert; import org.junit.Test; @@ -40,4 +42,31 @@ public void testEldestEntriesRemoval() { Assert.assertEquals(0, cache.getMessageCountAndThenIncrement("2")); } + @Test + public void cacheSizeSmallerThanOneIsRejected() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> new LRUMessageCache(0)); + Assert.assertEquals("Cache size cannot be smaller than 1", e.getMessage()); + } + + @Test + public void cacheSizeOfOneIsAccepted() { + final LRUMessageCache cache = new LRUMessageCache(1); + Assert.assertEquals(1, cache.cacheSize); + Assert.assertEquals(0, cache.getMessageCountAndThenIncrement("0")); + Assert.assertEquals(1, cache.getMessageCountAndThenIncrement("0")); + } + + @Test + public void clearForgetsAllCounts() { + final LRUMessageCache cache = new LRUMessageCache(10); + Assert.assertEquals(0, cache.getMessageCountAndThenIncrement("a")); + Assert.assertEquals(1, cache.getMessageCountAndThenIncrement("a")); + Assert.assertEquals(0, cache.getMessageCountAndThenIncrement("b")); + + cache.clear(); + + Assert.assertTrue(cache.isEmpty()); + Assert.assertEquals(0, cache.getMessageCountAndThenIncrement("a")); + } + } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/turbo/MarkerFilterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/turbo/MarkerFilterTest.java index 886e8d30c..3e89360a9 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/turbo/MarkerFilterTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/turbo/MarkerFilterTest.java @@ -23,9 +23,14 @@ import org.slf4j.Marker; import org.slf4j.MarkerFactory; +import ch.qos.logback.core.ContextBase; import ch.qos.logback.core.spi.FilterReply; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; +import java.util.Arrays; import java.util.Collections; +import java.util.regex.Pattern; public class MarkerFilterTest { @@ -78,4 +83,44 @@ public void testComposite() { assertEquals(FilterReply.ACCEPT, mkt.decide(Collections.singletonList(compositeMarker), null, null, null, null, null)); } + @Test + public void markersNotContainingTheMarkerToMatchYieldOnMismatch() { + Marker unrelated = MarkerFactory.getDetachedMarker("UNRELATED"); + MarkerFilter mkt = new MarkerFilter(); + mkt.setMarker(TOTO); + mkt.setOnMatch("ACCEPT"); + mkt.setOnMismatch("DENY"); + mkt.start(); + + assertEquals(FilterReply.DENY, mkt.decide(Collections.emptyList(), null, null, null, null, null)); + assertEquals(FilterReply.DENY, mkt.decide(Collections.singletonList(unrelated), null, null, null, null, null)); + assertEquals(FilterReply.ACCEPT, mkt.decide(Arrays.asList(unrelated, totoMarker), null, null, null, null, null)); + } + + @Test + public void settingANullMarkerKeepsThePreviousMarker() { + MarkerFilter mkt = new MarkerFilter(); + mkt.setMarker(TOTO); + mkt.setMarker(null); + mkt.setOnMatch("ACCEPT"); + mkt.start(); + + assertTrue(mkt.isStarted()); + assertEquals(FilterReply.ACCEPT, mkt.decide(Collections.singletonList(totoMarker), null, null, null, null, null)); + } + + @Test + public void settingOnlyANullMarkerLeavesTheFilterUnstarted() { + ContextBase context = new ContextBase(); + MarkerFilter mkt = new MarkerFilter(); + mkt.setContext(context); + mkt.setName("mkt"); + mkt.setMarker(null); + mkt.start(); + + assertFalse(mkt.isStarted()); + new StatusChecker(context).assertContainsMatch(Status.ERROR, + Pattern.quote("The marker property must be set for [mkt]")); + } + } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/turbo/MatchingFilterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/turbo/MatchingFilterTest.java new file mode 100644 index 000000000..4e7f9e0ab --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/turbo/MatchingFilterTest.java @@ -0,0 +1,94 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.turbo; + +import static org.junit.Assert.assertEquals; + +import java.util.List; + +import org.junit.Test; +import org.slf4j.Marker; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.core.spi.FilterReply; + +public class MatchingFilterTest { + + MatchingFilter filter = new MatchingFilter() { + @Override + public FilterReply decide(List markers, Logger logger, Level level, String format, Object[] params, + Throwable t) { + return onMatch; + } + }; + + @Test + public void onMatchAndOnMismatchDefaultToNeutral() { + assertEquals(FilterReply.NEUTRAL, filter.onMatch); + assertEquals(FilterReply.NEUTRAL, filter.onMismatch); + } + + @Test + public void setOnMatchAcceptsNeutralAcceptAndDeny() { + filter.setOnMatch("ACCEPT"); + assertEquals(FilterReply.ACCEPT, filter.onMatch); + filter.setOnMatch("DENY"); + assertEquals(FilterReply.DENY, filter.onMatch); + filter.setOnMatch("NEUTRAL"); + assertEquals(FilterReply.NEUTRAL, filter.onMatch); + filter.setOnMatch("DENY"); + assertEquals(FilterReply.DENY, filter.onMatch); + + assertEquals(FilterReply.NEUTRAL, filter.onMismatch); + } + + @Test + public void setOnMatchIgnoresUnknownActions() { + filter.setOnMatch("DENY"); + + filter.setOnMatch("REJECT"); + filter.setOnMatch("accept"); + filter.setOnMatch(null); + + assertEquals(FilterReply.DENY, filter.onMatch); + } + + @Test + public void setOnMismatchAcceptsNeutralAcceptAndDeny() { + filter.setOnMismatch("ACCEPT"); + assertEquals(FilterReply.ACCEPT, filter.onMismatch); + filter.setOnMismatch("DENY"); + assertEquals(FilterReply.DENY, filter.onMismatch); + filter.setOnMismatch("NEUTRAL"); + assertEquals(FilterReply.NEUTRAL, filter.onMismatch); + filter.setOnMismatch("ACCEPT"); + assertEquals(FilterReply.ACCEPT, filter.onMismatch); + + assertEquals(FilterReply.NEUTRAL, filter.onMatch); + } + + @Test + public void setOnMismatchIgnoresUnknownActions() { + filter.setOnMismatch("ACCEPT"); + + filter.setOnMismatch("REJECT"); + filter.setOnMismatch("deny"); + filter.setOnMismatch(null); + + assertEquals(FilterReply.ACCEPT, filter.onMismatch); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/turbo/ReconfigureOnChangeFilterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/turbo/ReconfigureOnChangeFilterTest.java new file mode 100644 index 000000000..66371422f --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/turbo/ReconfigureOnChangeFilterTest.java @@ -0,0 +1,521 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.turbo; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ScheduledExecutorService; +import java.util.regex.Pattern; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedConstruction; +import org.xml.sax.helpers.AttributesImpl; +import org.xml.sax.helpers.LocatorImpl; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.joran.JoranConfigurator; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.joran.event.SaxEvent; +import ch.qos.logback.core.joran.event.SaxEventRecorder; +import ch.qos.logback.core.joran.spi.ConfigurationWatchList; +import ch.qos.logback.core.joran.spi.JoranException; +import ch.qos.logback.core.joran.util.ConfigurationWatchListUtil; +import ch.qos.logback.core.spi.FilterReply; +import ch.qos.logback.core.status.ErrorStatus; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; + +public class ReconfigureOnChangeFilterTest { + + static final String MARKER_KEY = "rocFilterMarker"; + static final String FALLING_BACK = "Falling back to previously registered safe configuration."; + static final String RE_REGISTERING = "Re-registering previous fallback configuration once more as a fallback configuration point"; + static final String NO_PREVIOUS_CONFIGURATION = "No previous configuration to fall back on."; + static final String SAFE_CONFIGURATION_FAILED = "Unexpected exception thrown by a configuration considered safe."; + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + ExecutorCapturingLoggerContext loggerContext = new ExecutorCapturingLoggerContext(); + StatusChecker statusChecker = new StatusChecker(loggerContext); + ReconfigureOnChangeFilter filter = new ReconfigureOnChangeFilter(); + + @Before + public void setUp() { + loggerContext.setName("rocFilter"); + // reset() clears the context properties: the marker tells whether a reconfiguration took place + loggerContext.putProperty(MARKER_KEY, "present"); + filter.setContext(loggerContext); + } + + @Test + public void startWithoutWatchListWarnsAndLeavesFilterStopped() { + filter.start(); + + assertFalse(filter.isStarted()); + assertNull(filter.mainConfigurationURL); + statusChecker.assertContainsMatch(Status.WARN, Pattern.quote("Empty ConfigurationWatchList in context")); + } + + @Test + public void startWithoutMainUrlWarnsThatReconfigurationIsImpossible() { + ConfigurationWatchList watchList = new ConfigurationWatchList(); + watchList.setContext(loggerContext); + ConfigurationWatchListUtil.registerConfigurationWatchList(loggerContext, watchList); + + filter.start(); + + assertFalse(filter.isStarted()); + statusChecker.assertContainsMatch(Status.WARN, + Pattern.quote("Due to missing top level configuration file, automatic reconfiguration is impossible.")); + } + + @Test + public void startWithMainUrlSchedulesTheFirstCheckOneRefreshPeriodLater() throws IOException { + File mainFile = tmp.newFile("main.xml"); + URL mainUrl = registerWatchListOn(mainFile); + filter.setRefreshPeriod(5000); + + long before = System.currentTimeMillis(); + filter.start(); + long after = System.currentTimeMillis(); + + assertTrue(filter.isStarted()); + assertEquals(mainUrl, filter.mainConfigurationURL); + assertTrue("nextCheck=" + filter.nextCheck, + filter.nextCheck >= before + 5000 && filter.nextCheck <= after + 5000); + statusChecker.assertContainsMatch(Status.INFO, + Pattern.quote("Will scan for changes in [[" + mainFile + "]] every 5 seconds. ")); + } + + @Test + public void refreshPeriodDefaultsToOneMinute() throws IOException { + assertEquals(60 * 1000L, ReconfigureOnChangeFilter.DEFAULT_REFRESH_PERIOD); + assertEquals(ReconfigureOnChangeFilter.DEFAULT_REFRESH_PERIOD, filter.getRefreshPeriod()); + File mainFile = tmp.newFile("main.xml"); + registerWatchListOn(mainFile); + + filter.start(); + + statusChecker.assertContainsMatch(Status.INFO, + Pattern.quote("Will scan for changes in [[" + mainFile + "]] every 60 seconds. ")); + filter.setRefreshPeriod(1234); + assertEquals(1234, filter.getRefreshPeriod()); + } + + @Test + public void decideIsNeutralAndInertWhenNotStarted() { + for (int i = 0; i < 64; i++) { + assertEquals(FilterReply.NEUTRAL, decide()); + } + + assertEquals("ReconfigureOnChangeFilter{invocationCounter=0}", filter.toString()); + verifyNoInteractions(loggerContext.executor); + } + + @Test + public void decideLooksForChangesOnlyOnceEverySixteenCalls() throws IOException { + StubWatchList watchList = startWithStubWatchList(false); + filter.nextCheck = 0; // a check is due + + for (int i = 0; i < 15; i++) { + assertEquals(FilterReply.NEUTRAL, decide()); + } + assertEquals(0, watchList.changeDetectedCalls); + + assertEquals(FilterReply.NEUTRAL, decide()); + assertEquals(1, watchList.changeDetectedCalls); + assertEquals("ReconfigureOnChangeFilter{invocationCounter=16}", filter.toString()); + verifyNoInteractions(loggerContext.executor); + } + + @Test + public void decideDetachesReconfigurationOnlyOnceWhenAChangeIsDetected() throws IOException { + StubWatchList watchList = startWithStubWatchList(true); + filter.nextCheck = 0; // a check is due + + for (int i = 0; i < 16; i++) { + assertEquals(FilterReply.NEUTRAL, decide()); + } + + assertEquals(1, watchList.changeDetectedCalls); + assertEquals(Long.MAX_VALUE, filter.nextCheck); + statusChecker.assertContainsMatch(Status.INFO, + Pattern.quote("Detected change in [" + watchList.getCopyOfFileWatchList() + "]")); + ArgumentCaptor submitted = ArgumentCaptor.forClass(Runnable.class); + verify(loggerContext.executor).submit(submitted.capture()); + assertTrue(submitted.getValue() instanceof ReconfigureOnChangeFilter.ReconfiguringThread); + + // subsequent checks never consult the watch list again + for (int i = 0; i < 256; i++) { + assertEquals(FilterReply.NEUTRAL, decide()); + } + assertEquals(1, watchList.changeDetectedCalls); + verify(loggerContext.executor).submit(any(Runnable.class)); + } + + @Test + public void maskGrowsWhenChecksComeInQuickSuccession() throws Exception { + startWithStubWatchList(false); + setField("mask", 0); // check on every call + + // a last check "in the future" means less than MASK_INCREASE_THRESHOLD ms elapsed + setField("lastMaskCheck", Long.MAX_VALUE); + long before = System.currentTimeMillis(); + decide(); + long after = System.currentTimeMillis(); + assertEquals(0x1, getField("mask")); + long lastMaskCheck = getField("lastMaskCheck"); + assertTrue(lastMaskCheck >= before && lastMaskCheck <= after); + + setField("lastMaskCheck", Long.MAX_VALUE); + decide(); + assertEquals(0x3, getField("mask")); + } + + @Test + public void maskStopsGrowingAtMaxMask() throws Exception { + startWithStubWatchList(false); + setField("mask", 0xFFFF); + setField("invocationCounter", 0xFFFF); + setField("lastMaskCheck", Long.MAX_VALUE); + + decide(); + + assertEquals(0xFFFF, getField("mask")); + assertTrue(getField("lastMaskCheck") != Long.MAX_VALUE); + } + + @Test + public void maskShrinksFourfoldWhenChecksAreFarApart() throws Exception { + StubWatchList watchList = startWithStubWatchList(false); + filter.nextCheck = 0; + // more than MASK_DECREASE_THRESHOLD ms elapsed since the last check + setField("lastMaskCheck", 0); + + for (int i = 0; i < 16; i++) { + decide(); + } + + assertEquals(1, watchList.changeDetectedCalls); + assertEquals(0xF >>> 2, getField("mask")); + } + + @Test + public void changeDetectedIsFalseWithoutConsultingTheWatchListBeforeNextCheck() throws IOException { + StubWatchList watchList = startWithStubWatchList(true); + filter.nextCheck = 1000; + + assertFalse(filter.changeDetected(999)); + + assertEquals(0, watchList.changeDetectedCalls); + assertEquals(1000, filter.nextCheck); + } + + @Test + public void changeDetectedReportsTheWatchListVerdictAndSchedulesTheNextCheckOnceDue() throws IOException { + StubWatchList watchList = startWithStubWatchList(true); + filter.setRefreshPeriod(50); + filter.nextCheck = 1000; + + assertTrue(filter.changeDetected(1000)); + assertEquals(1050, filter.nextCheck); + + watchList.changed = false; + assertFalse(filter.changeDetected(1060)); + assertEquals(1110, filter.nextCheck); + assertEquals(2, watchList.changeDetectedCalls); + } + + @Test + public void reconfiguringThreadSkipsReconfigurationWithoutMainUrl() { + filter.new ReconfiguringThread().run(); + + statusChecker.assertContainsMatch(Status.INFO, + Pattern.quote("Due to missing top level configuration file, skipping reconfiguration")); + statusChecker.assertNoMatch(Pattern.quote(CoreConstants.RESET_MSG_PREFIX)); + assertContextWasNotReset(); + } + + @Test + public void reconfiguringThreadLeavesContextAloneWhenMainUrlIsNotXml() throws IOException { + registerWatchListOn(tmp.newFile("logback.properties")); + filter.start(); + + filter.new ReconfiguringThread().run(); + + statusChecker.assertContainsMatch(Status.INFO, + Pattern.quote(CoreConstants.RESET_MSG_PREFIX + "named [rocFilter]")); + statusChecker.assertIsWarningOrErrorFree(); + assertContextWasNotReset(); + } + + @Test + public void reconfiguringThreadReconfiguresTheContextFromTheMainXmlFile() throws Exception { + URL mainUrl = registerWatchListOn(tmp.newFile("main.xml")); + filter.start(); + + try (MockedConstruction configurators = mockConstruction(JoranConfigurator.class)) { + filter.new ReconfiguringThread().run(); + + assertEquals("no fallback configurator expected", 1, configurators.constructed().size()); + JoranConfigurator configurator = configurators.constructed().get(0); + verify(configurator).setContext(loggerContext); + verify(configurator).doConfigure(mainUrl); + } + + assertContextWasReset(); + statusChecker.assertContainsMatch(Status.INFO, + Pattern.quote(CoreConstants.RESET_MSG_PREFIX + "named [rocFilter]")); + statusChecker.assertIsWarningOrErrorFree(); + } + + @Test + public void reconfiguringThreadFallsBackToSafeConfigurationOnXmlParsingErrors() throws Exception { + final URL mainUrl = registerWatchListOn(tmp.newFile("main.xml")); + filter.start(); + final List safeEvents = configurationWithRootLevel(Level.WARN); + + try (MockedConstruction configurators = mockConstruction(JoranConfigurator.class, + (mock, ctx) -> { + if (ctx.getCount() == 1) { + when(mock.recallSafeConfiguration()).thenReturn(safeEvents); + doAnswer(invocation -> { + loggerContext.getStatusManager().add(new ErrorStatus( + CoreConstants.XML_PARSING + " - Parsing fatal error on line 2 and column 9", mock)); + return null; + }).when(mock).doConfigure(mainUrl); + } + })) { + filter.new ReconfiguringThread().run(); + + assertEquals(2, configurators.constructed().size()); + JoranConfigurator fallbackConfigurator = configurators.constructed().get(1); + verify(fallbackConfigurator).setContext(loggerContext); + verify(fallbackConfigurator).doConfigure(safeEvents); + verify(fallbackConfigurator).registerSafeConfiguration(safeEvents); + } + + statusChecker.assertContainsMatch(Status.WARN, Pattern.quote(FALLING_BACK)); + statusChecker.assertContainsMatch(Status.INFO, Pattern.quote(RE_REGISTERING)); + // the context is told about the main URL again, so that it keeps being watched + assertEquals(mainUrl, ConfigurationWatchListUtil.getMainWatchURL(loggerContext)); + } + + @Test + public void reconfiguringThreadFallsBackToSafeConfigurationWhenMainXmlCannotBeRead() throws IOException { + URL missingXml = registerWatchListOn(new File(tmp.getRoot(), "missing.xml")); + filter.start(); + List safeEvents = configurationWithRootLevel(Level.WARN); + JoranConfigurator safeConfigurator = new JoranConfigurator(); + safeConfigurator.setContext(loggerContext); + safeConfigurator.registerSafeConfiguration(safeEvents); + assertEquals(Level.DEBUG, rootLevel()); + + filter.new ReconfiguringThread().run(); + + assertContextWasReset(); + statusChecker.assertContainsMatch(Status.ERROR, Pattern.quote("Could not open URL [" + missingXml + "].")); + statusChecker.assertContainsMatch(Status.WARN, Pattern.quote(FALLING_BACK)); + statusChecker.assertContainsMatch(Status.INFO, Pattern.quote(RE_REGISTERING)); + statusChecker.assertNoMatch(Pattern.quote(NO_PREVIOUS_CONFIGURATION)); + // the safe configuration was replayed and registered once more + assertEquals(Level.WARN, rootLevel()); + assertSame(safeEvents, loggerContext.getObject(CoreConstants.SAFE_JORAN_CONFIGURATION)); + assertEquals(missingXml, ConfigurationWatchListUtil.getMainWatchURL(loggerContext)); + } + + @Test + public void reconfiguringThreadWarnsWhenThereIsNoSafeConfigurationToFallBackOn() throws IOException { + URL missingXml = registerWatchListOn(new File(tmp.getRoot(), "missing.xml")); + filter.start(); + + filter.new ReconfiguringThread().run(); + + assertContextWasReset(); + statusChecker.assertContainsMatch(Status.ERROR, Pattern.quote("Could not open URL [" + missingXml + "].")); + statusChecker.assertContainsMatch(Status.WARN, Pattern.quote(NO_PREVIOUS_CONFIGURATION)); + statusChecker.assertNoMatch(Pattern.quote(FALLING_BACK)); + } + + @Test + public void reconfiguringThreadReportsAFailingSafeConfigurationAsAnError() throws Exception { + final URL mainUrl = registerWatchListOn(tmp.newFile("main.xml")); + filter.start(); + final List safeEvents = configurationWithRootLevel(Level.WARN); + final JoranException safeFailure = new JoranException("safe configuration is broken"); + + try (MockedConstruction configurators = mockConstruction(JoranConfigurator.class, + (mock, ctx) -> { + if (ctx.getCount() == 1) { + when(mock.recallSafeConfiguration()).thenReturn(safeEvents); + doThrow(new JoranException("main configuration is broken")).when(mock).doConfigure(mainUrl); + } else { + doThrow(safeFailure).when(mock).doConfigure(anyList()); + } + })) { + filter.new ReconfiguringThread().run(); + + assertEquals(2, configurators.constructed().size()); + JoranConfigurator fallbackConfigurator = configurators.constructed().get(1); + verify(fallbackConfigurator).doConfigure(safeEvents); + verify(fallbackConfigurator, never()).registerSafeConfiguration(any()); + } + + statusChecker.assertContainsMatch(Status.WARN, Pattern.quote(FALLING_BACK)); + Status error = findStatus(SAFE_CONFIGURATION_FAILED); + assertEquals(Status.ERROR, error.getLevel()); + assertSame(safeFailure, error.getThrowable()); + statusChecker.assertNoMatch(Pattern.quote(RE_REGISTERING)); + } + + private FilterReply decide() { + return filter.decide(null, null, Level.INFO, "msg", null, null); + } + + private URL registerWatchListOn(File file) throws IOException { + URL url = file.toURI().toURL(); + ConfigurationWatchListUtil.setMainWatchURL(loggerContext, url); + return url; + } + + private StubWatchList startWithStubWatchList(boolean changed) throws IOException { + File mainFile = tmp.newFile("main.xml"); + StubWatchList watchList = new StubWatchList(mainFile.toURI().toURL(), + Collections.singletonList(mainFile), changed); + ConfigurationWatchListUtil.registerConfigurationWatchList(loggerContext, watchList); + filter.start(); + assertTrue(filter.isStarted()); + return watchList; + } + + private long getField(String name) throws Exception { + return declaredField(name).getLong(filter); + } + + private void setField(String name, long value) throws Exception { + declaredField(name).setLong(filter, value); + } + + private static Field declaredField(String name) throws NoSuchFieldException { + Field field = ReconfigureOnChangeFilter.class.getDeclaredField(name); + field.setAccessible(true); + return field; + } + + private void assertContextWasNotReset() { + assertEquals("present", loggerContext.getProperty(MARKER_KEY)); + } + + private void assertContextWasReset() { + assertNull("the context should have been reset", loggerContext.getProperty(MARKER_KEY)); + } + + private Level rootLevel() { + return loggerContext.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME).getLevel(); + } + + private Status findStatus(String message) { + for (Status s : loggerContext.getStatusManager().getCopyOfStatusList()) { + if (message.equals(s.getMessage())) { + return s; + } + } + throw new AssertionError("no status [" + message + "]"); + } + + /** + * The SAX events of {@code }, + * recorded through the public ContentHandler API, so no XML parser is needed. + */ + private List configurationWithRootLevel(Level level) { + SaxEventRecorder recorder = new SaxEventRecorder(loggerContext); + recorder.setDocumentLocator(new LocatorImpl()); + recorder.startElement("", "configuration", "configuration", new AttributesImpl()); + AttributesImpl rootAttributes = new AttributesImpl(); + rootAttributes.addAttribute("", "level", "level", "CDATA", level.toString()); + recorder.startElement("", "root", "root", rootAttributes); + recorder.endElement("", "root", "root"); + recorder.endElement("", "configuration", "configuration"); + return recorder.getSaxEventList(); + } + + /** A logger context whose executor only records what is submitted to it. */ + static class ExecutorCapturingLoggerContext extends LoggerContext { + final ScheduledExecutorService executor = mock(ScheduledExecutorService.class); + + @Override + public synchronized ScheduledExecutorService getScheduledExecutorService() { + return executor; + } + } + + /** A watch list with a fixed main URL and file list, and a settable change status. */ + static class StubWatchList extends ConfigurationWatchList { + final URL mainUrl; + final List files; + boolean changed; + int changeDetectedCalls; + + StubWatchList(URL mainUrl, List files, boolean changed) { + this.mainUrl = mainUrl; + this.files = files; + this.changed = changed; + } + + @Override + public URL getMainURL() { + return mainUrl; + } + + @Override + public List getCopyOfFileWatchList() { + return new ArrayList(files); + } + + @Override + public boolean changeDetected() { + changeDetectedCalls++; + return changed; + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/turbo/TurboFilterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/turbo/TurboFilterTest.java new file mode 100644 index 000000000..9d07be154 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/turbo/TurboFilterTest.java @@ -0,0 +1,48 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.turbo; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class TurboFilterTest { + + TurboFilter filter = new NOPTurboFilter(); + + @Test + public void nameIsUnsetUntilSet() { + assertNull(filter.getName()); + + filter.setName("nop"); + + assertEquals("nop", filter.getName()); + } + + @Test + public void startAndStopToggleTheStartedState() { + assertFalse(filter.isStarted()); + + filter.start(); + assertTrue(filter.isStarted()); + + filter.stop(); + assertFalse(filter.isStarted()); + } +} From 5fecbcf81ace02b65ba12d53f522ae1215ebe04f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:10:12 +0000 Subject: [PATCH 30/79] test: pin reset, Android properties and include filtering on reconfiguration Mutation checks showed that the plain-JVM tests of ReconfigureOnChangeTask and ReconfigureOnChangeFilter did not notice a dropped lc.reset() before the safe configuration is replayed, a dropped AndroidContextUtil setupProperties() call after either reset, or (for the task) a dropped "no change detected" return and an inverted XML-parsing-error check. - ReconfigureOnChangeTaskJvmTest: add tests for an unchanged watch list, a successful XML reconfiguration, and a fallback after XML parsing errors that checks that events are left out of the replay while the whole safe configuration is registered again; add toString(). The task's lines and branches are now fully covered without the Robolectric-only ReconfigureOnChangeTaskTest. - ReconfigureOnChangeFilterTest: check that a partly applied broken configuration is reset before the fallback and that the Android properties are set up again after the last reset. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../joran/ReconfigureOnChangeTaskJvmTest.java | 131 ++++++++++++++++++ .../turbo/ReconfigureOnChangeFilterTest.java | 13 ++ 2 files changed, 144 insertions(+) diff --git a/logback-android/src/test/java/ch/qos/logback/classic/joran/ReconfigureOnChangeTaskJvmTest.java b/logback-android/src/test/java/ch/qos/logback/classic/joran/ReconfigureOnChangeTaskJvmTest.java index 353fa0c00..5bf260bfe 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/joran/ReconfigureOnChangeTaskJvmTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/joran/ReconfigureOnChangeTaskJvmTest.java @@ -44,6 +44,7 @@ import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import org.mockito.ArgumentCaptor; import org.mockito.MockedConstruction; import org.xml.sax.helpers.AttributesImpl; import org.xml.sax.helpers.LocatorImpl; @@ -56,6 +57,7 @@ import ch.qos.logback.core.joran.spi.ConfigurationWatchList; import ch.qos.logback.core.joran.spi.JoranException; import ch.qos.logback.core.joran.util.ConfigurationWatchListUtil; +import ch.qos.logback.core.status.ErrorStatus; import ch.qos.logback.core.status.Status; import ch.qos.logback.core.status.StatusChecker; @@ -68,6 +70,7 @@ public class ReconfigureOnChangeTaskJvmTest { static final String MARKER_KEY = "rocTaskMarker"; + static final String NEW_CONFIGURATION_KEY = "rocTaskNewConfiguration"; static final String NO_PREVIOUS_CONFIGURATION = "No previous configuration to fall back on."; static final String SAFE_CONFIGURATION_FAILED = "Unexpected exception thrown by a configuration considered safe."; @@ -242,6 +245,114 @@ public void failingSafeConfigurationIsReportedAsAnError() throws Exception { assertEquals(mainXml, watchList.getMainURL()); } + @Test + public void runLeavesTheContextAloneWhenNoChangeIsDetected() throws IOException { + StubWatchList watchList = new StubWatchList(xmlUrl("main.xml"), + Collections.singletonList(new File(tmp.getRoot(), "main.xml")), false); + ConfigurationWatchListUtil.registerConfigurationWatchList(loggerContext, watchList); + task.addListener(new RecordingListener("l")); + + try (MockedConstruction configurators = mockConstruction(JoranConfigurator.class)) { + task.run(); + + assertEquals("no reconfiguration expected", 0, configurators.constructed().size()); + } + + assertEquals(1, watchList.changeDetectedCalls); + assertEquals(Collections.singletonList("l:entered"), notifications); + statusChecker.assertNoMatch(Pattern.quote(DETECTED_CHANGE_IN_CONFIGURATION_FILES)); + assertContextWasNotReset(); + } + + @Test + public void changedMainXmlReconfiguresTheResetContextWithoutFallingBack() throws Exception { + final URL mainXml = xmlUrl("main.xml"); + registerChangedWatchList(mainXml); + task.addListener(new RecordingListener("l")); + + try (MockedConstruction configurators = mockConstruction(JoranConfigurator.class, + (mock, ctx) -> doAnswer(invocation -> { + JoranConfigurator.informContextOfURLUsedForConfiguration(loggerContext, mainXml); + loggerContext.putProperty(NEW_CONFIGURATION_KEY, "applied"); + return null; + }).when(mock).doConfigure(mainXml))) { + + task.run(); + + assertEquals("no fallback configurator expected", 1, configurators.constructed().size()); + verify(configurators.constructed().get(0)).setContext(loggerContext); + } + + assertNull("the context should have been reset", loggerContext.getProperty(MARKER_KEY)); + assertNotNull("the Android properties should be set up again after the reset", + loggerContext.getProperty(CoreConstants.PACKAGE_NAME_KEY)); + assertEquals("applied", loggerContext.getProperty(NEW_CONFIGURATION_KEY)); + assertEquals(mainXml, ConfigurationWatchListUtil.getMainWatchURL(loggerContext)); + statusChecker.assertIsWarningOrErrorFree(); + assertEquals(Arrays.asList("l:entered", "l:changeDetected", "l:done"), notifications); + } + + @Test + public void xmlParsingErrorsFallBackToSafeConfigurationWithoutItsIncludes() throws Exception { + final URL mainXml = xmlUrl("main.xml"); + registerChangedWatchList(mainXml); + final List safeEvents = configurationWithIncludeAndRootLevel(Level.WARN); + assertEquals(6, safeEvents.size()); + + List replayedEvents; + try (MockedConstruction configurators = mockConstruction(JoranConfigurator.class, + (mock, ctx) -> { + if (ctx.getCount() == 1) { + // the configurator of the main file: it applies part of the file and reports a parsing error + when(mock.recallSafeConfiguration()).thenReturn(safeEvents); + doAnswer(invocation -> { + JoranConfigurator.informContextOfURLUsedForConfiguration(loggerContext, mainXml); + loggerContext.putProperty(NEW_CONFIGURATION_KEY, "partly applied"); + loggerContext.getStatusManager().add(new ErrorStatus( + CoreConstants.XML_PARSING + " - Parsing fatal error on line 2 and column 9", mock)); + return null; + }).when(mock).doConfigure(mainXml); + } + })) { + + task.run(); + + assertEquals(2, configurators.constructed().size()); + JoranConfigurator fallbackConfigurator = configurators.constructed().get(1); + verify(fallbackConfigurator).setContext(loggerContext); + @SuppressWarnings("unchecked") + ArgumentCaptor> replayed = ArgumentCaptor.forClass(List.class); + verify(fallbackConfigurator).doConfigure(replayed.capture()); + replayedEvents = replayed.getValue(); + // the whole safe configuration, includes and all, is registered once more + verify(fallbackConfigurator).registerSafeConfiguration(safeEvents); + } + + // only the start and end events are left out of the replay + List expected = new ArrayList(safeEvents); + expected.remove(safeEvents.get(2)); + expected.remove(safeEvents.get(1)); + assertEquals(expected, replayedEvents); + + assertNull("the partly applied configuration should have been reset", + loggerContext.getProperty(NEW_CONFIGURATION_KEY)); + assertNotNull("the Android properties should be set up again after the second reset", + loggerContext.getProperty(CoreConstants.PACKAGE_NAME_KEY)); + statusChecker.assertContainsMatch(Status.WARN, Pattern.quote(FALLING_BACK_TO_SAFE_CONFIGURATION)); + statusChecker.assertContainsMatch(Status.INFO, Pattern.quote(RE_REGISTERING_PREVIOUS_SAFE_CONFIGURATION)); + statusChecker.assertContainsMatch(Status.INFO, + Pattern.quote("after registerSafeConfiguration: " + safeEvents)); + statusChecker.assertNoMatch(Pattern.quote(NO_PREVIOUS_CONFIGURATION)); + assertEquals(mainXml, ConfigurationWatchListUtil.getMainWatchURL(loggerContext)); + } + + @Test + public void toStringShowsTheBirthdate() { + task.birthdate = 1234L; + + assertEquals("ReconfigureOnChangeTask(born:1234)", task.toString()); + } + private URL xmlUrl(String name) throws IOException { return new File(tmp.getRoot(), name).toURI().toURL(); } @@ -286,6 +397,26 @@ private List configurationWithRootLevel(Level level) { return recorder.getSaxEventList(); } + /** + * The SAX events of + * {@code }. + */ + private List configurationWithIncludeAndRootLevel(Level level) { + SaxEventRecorder recorder = new SaxEventRecorder(loggerContext); + recorder.setDocumentLocator(new LocatorImpl()); + recorder.startElement("", "configuration", "configuration", new AttributesImpl()); + AttributesImpl includeAttributes = new AttributesImpl(); + includeAttributes.addAttribute("", "file", "file", "CDATA", "included.xml"); + recorder.startElement("", "include", "include", includeAttributes); + recorder.endElement("", "include", "include"); + AttributesImpl rootAttributes = new AttributesImpl(); + rootAttributes.addAttribute("", "level", "level", "CDATA", level.toString()); + recorder.startElement("", "root", "root", rootAttributes); + recorder.endElement("", "root", "root"); + recorder.endElement("", "configuration", "configuration"); + return recorder.getSaxEventList(); + } + /** A watch list with a fixed main URL, file list and change status. */ static class StubWatchList extends ConfigurationWatchList { final URL mainUrl; diff --git a/logback-android/src/test/java/ch/qos/logback/classic/turbo/ReconfigureOnChangeFilterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/turbo/ReconfigureOnChangeFilterTest.java index 66371422f..b82745448 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/turbo/ReconfigureOnChangeFilterTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/turbo/ReconfigureOnChangeFilterTest.java @@ -17,6 +17,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; @@ -67,6 +68,7 @@ public class ReconfigureOnChangeFilterTest { static final String MARKER_KEY = "rocFilterMarker"; + static final String NEW_CONFIGURATION_KEY = "rocFilterNewConfiguration"; static final String FALLING_BACK = "Falling back to previously registered safe configuration."; static final String RE_REGISTERING = "Re-registering previous fallback configuration once more as a fallback configuration point"; static final String NO_PREVIOUS_CONFIGURATION = "No previous configuration to fall back on."; @@ -304,6 +306,7 @@ public void reconfiguringThreadReconfiguresTheContextFromTheMainXmlFile() throws } assertContextWasReset(); + assertAndroidPropertiesWereSetUp(); statusChecker.assertContainsMatch(Status.INFO, Pattern.quote(CoreConstants.RESET_MSG_PREFIX + "named [rocFilter]")); statusChecker.assertIsWarningOrErrorFree(); @@ -320,6 +323,8 @@ public void reconfiguringThreadFallsBackToSafeConfigurationOnXmlParsingErrors() if (ctx.getCount() == 1) { when(mock.recallSafeConfiguration()).thenReturn(safeEvents); doAnswer(invocation -> { + // part of the broken file is applied before the parsing error + loggerContext.putProperty(NEW_CONFIGURATION_KEY, "partly applied"); loggerContext.getStatusManager().add(new ErrorStatus( CoreConstants.XML_PARSING + " - Parsing fatal error on line 2 and column 9", mock)); return null; @@ -335,6 +340,9 @@ public void reconfiguringThreadFallsBackToSafeConfigurationOnXmlParsingErrors() verify(fallbackConfigurator).registerSafeConfiguration(safeEvents); } + assertNull("the partly applied configuration should have been reset", + loggerContext.getProperty(NEW_CONFIGURATION_KEY)); + assertAndroidPropertiesWereSetUp(); statusChecker.assertContainsMatch(Status.WARN, Pattern.quote(FALLING_BACK)); statusChecker.assertContainsMatch(Status.INFO, Pattern.quote(RE_REGISTERING)); // the context is told about the main URL again, so that it keeps being watched @@ -450,6 +458,11 @@ private void assertContextWasReset() { assertNull("the context should have been reset", loggerContext.getProperty(MARKER_KEY)); } + private void assertAndroidPropertiesWereSetUp() { + assertNotNull("the Android properties should be set up again after the last reset", + loggerContext.getProperty(CoreConstants.PACKAGE_NAME_KEY)); + } + private Level rootLevel() { return loggerContext.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME).getLevel(); } From ada2cd924b1b50bb5e731706e3ccb24d1fd97f3c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:41:32 +0000 Subject: [PATCH 31/79] test: read the SMTP part's content type without naming DataHandler javax.activation.DataHandler implements java.awt.datatransfer.Transferable, which Android's Java library lacks, so the unit tests (compiled against it) failed to compile. Read the content type reflectively instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../ch/qos/logback/core/net/SMTPAppenderBaseTest.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/SMTPAppenderBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/SMTPAppenderBaseTest.java index 80c71cece..10296cc0d 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/SMTPAppenderBaseTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/SMTPAppenderBaseTest.java @@ -176,8 +176,15 @@ private static String body(MimeMessage message) throws Exception { return (String) onlyBodyPart(message).getContent(); } - private static String contentType(MimeBodyPart part) throws MessagingException { - return part.getDataHandler().getContentType(); + /** + * The content type the appender gave the part, as held by its DataHandler. + * Read reflectively: DataHandler implements java.awt.datatransfer.Transferable, + * which Android's Java library (that the unit tests are compiled against) + * doesn't have, so the test can't name the type. + */ + private static String contentType(MimeBodyPart part) throws Exception { + Object dataHandler = MimeBodyPart.class.getMethod("getDataHandler").invoke(part); + return (String) dataHandler.getClass().getMethod("getContentType").invoke(dataHandler); } private static List addresses(Address[] addresses) { From e7a6c48e2ddf08e970f63cdfe5fa2ff0bdea30e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:16:29 +0000 Subject: [PATCH 32/79] fix: start an SSL server from SimpleSSLSocketServer.main SimpleSocketServer.doMain(serverClass, argv) ignored serverClass and always created a plain SimpleSocketServer, so running SimpleSSLSocketServer.main started a server without SSL, contrary to its documentation. doMain now instantiates serverClass through its public (LoggerContext, int) constructor, which both server classes have. SimpleSocketServerMainTest runs SimpleSSLSocketServer.main and checks that the server thread it starts is a SimpleSSLSocketServer (its thread is named after the class). It uses Robolectric because main configures the default context from XML through Android's parser. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../classic/net/SimpleSocketServer.java | 3 +- .../net/SimpleSocketServerMainTest.java | 168 ++++++++++++++++++ 2 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerMainTest.java diff --git a/logback-android/src/main/java/ch/qos/logback/classic/net/SimpleSocketServer.java b/logback-android/src/main/java/ch/qos/logback/classic/net/SimpleSocketServer.java index e34dcacd8..89c8a2104 100755 --- a/logback-android/src/main/java/ch/qos/logback/classic/net/SimpleSocketServer.java +++ b/logback-android/src/main/java/ch/qos/logback/classic/net/SimpleSocketServer.java @@ -78,7 +78,8 @@ protected static void doMain(Class serverClass, LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory(); configureLC(lc, configFile); - SimpleSocketServer sss = new SimpleSocketServer(lc, port); + SimpleSocketServer sss = serverClass.getConstructor(LoggerContext.class, int.class) + .newInstance(lc, port); sss.start(); } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerMainTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerMainTest.java new file mode 100644 index 000000000..31184550d --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerMainTest.java @@ -0,0 +1,168 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.net; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.slf4j.LoggerFactory; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.util.ContextInitializer; +import ch.qos.logback.core.AppenderBase; + +/** + * Tests the command-line entry points of {@link SimpleSocketServer} and + * {@link SimpleSSLSocketServer}. They configure the default context from an + * XML file, which needs Android's XML pull parser, hence Robolectric. + *

+ * The port given to {@code main} is out of the TCP range, so the server thread + * that {@code main} starts logs its failure to listen and ends on its own. + */ +@RunWith(RobolectricTestRunner.class) +public class SimpleSocketServerMainTest { + + private static final String OUT_OF_RANGE_PORT = "70000"; + private static final long TIMEOUT_MILLIS = 10000; + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private LoggerContext defaultContext; + + @Before + public void setUp() { + defaultContext = (LoggerContext) LoggerFactory.getILoggerFactory(); + } + + @After + public void tearDown() throws Exception { + // undo what main configured into the default context + defaultContext.reset(); + new ContextInitializer(defaultContext).autoConfig(); + } + + @Test + public void sslMainStartsAnSslSocketServer() throws Exception { + SimpleSSLSocketServer.main(new String[] {OUT_OF_RANGE_PORT, writeCapturingConfig()}); + + CapturingAppender capture = CapturingAppender.from(defaultContext); + assertNotNull("main did not apply the config file", capture); + ILoggingEvent failure = capture.awaitMessage("Unexpected failure in run method"); + capture.joinLoggingThreads(); + + assertNotNull(failure); + assertEquals("Logback SimpleSSLSocketServer (port " + OUT_OF_RANGE_PORT + ")", failure.getThreadName()); + } + + /** + * Writes a configuration that sends every event of the context to a + * {@link CapturingAppender} named {@code CAPTURE}. + */ + private String writeCapturingConfig() throws IOException { + File file = tmp.newFile("capture.xml"); + Writer writer = new OutputStreamWriter(new FileOutputStream(file), "UTF-8"); + try { + writer.write("\n" + + " \n" + + " \n" + + "\n"); + } finally { + writer.close(); + } + return file.getAbsolutePath(); + } + + /** + * An appender, configured by class name, that keeps the events it receives + * and the threads that logged them. + */ + public static class CapturingAppender extends AppenderBase { + private final BlockingQueue events = new LinkedBlockingQueue(); + private final List seen = new ArrayList(); + private final List threads = new ArrayList(); + + static CapturingAppender from(LoggerContext context) { + return (CapturingAppender) context.getLogger(Logger.ROOT_LOGGER_NAME).getAppender("CAPTURE"); + } + + @Override + protected void append(ILoggingEvent event) { + // resolve the thread name now, on the logging thread + event.prepareForDeferredProcessing(); + synchronized (threads) { + if (!threads.contains(Thread.currentThread())) { + threads.add(Thread.currentThread()); + } + } + events.offer(event); + } + + ILoggingEvent awaitMessage(String message) throws InterruptedException { + ILoggingEvent event; + while ((event = events.poll(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) != null) { + seen.add(event); + if (message.equals(event.getFormattedMessage())) { + return event; + } + } + return null; + } + + boolean hasMessage(String message) { + for (ILoggingEvent event : seen) { + if (message.equals(event.getFormattedMessage())) { + return true; + } + } + return false; + } + + void joinLoggingThreads() throws InterruptedException { + List copy; + synchronized (threads) { + copy = new ArrayList(threads); + } + for (Thread thread : copy) { + if (thread != Thread.currentThread()) { + thread.join(TIMEOUT_MILLIS); + assertFalse(thread.isAlive()); + } + } + } + } +} From 316b66e24e64885d9a0270d05af2c566e9ed4677 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:17:06 +0000 Subject: [PATCH 33/79] test: cover classic.net and classic.net.server receivers, servers and appenders Closes the unit-test coverage gaps of the classes in ch.qos.logback.classic.net and ch.qos.logback.classic.net.server. The new tests pin: - LoggingEventPreSerializationTransformer: null, LoggingEvent, value object and unsupported event types. - ReceiverBase: start without a context, shouldStart gating, and the error reported when onStop fails. - SocketReceiver and SocketNode: every way a connection ends (executor rejection, connector failure, end of stream, socket/IO errors, unknown event class, non-event objects), level filtering, reconnection delays and the default connector. They run on the test thread with scripted sockets and connectors, so they cover what the existing tests reached only through timing. - SimpleSocketServer and SimpleSSLSocketServer: accepting a client and stopping quietly on close, close before run, failures to listen and to close, the latch, port parsing, usage/exit (with Runtime mocked), the SSL server socket factory; main and configureLC under Robolectric. - SyslogAppender: severity mapping and how stack traces and causes are written, excluded, and cut short by write failures. - SSLSocketReceiver, SSL/ServerSocketAppender, SSL/ServerSocketReceiver and RemoteAppenderStreamClient: SSL setup failures, caller data, transformers, configured port/backlog/address, startup errors, and read/class/cast failures. SSLSocketReceiverTest and RemoteAppenderStreamClientTest now run on the plain JVM with their own LoggerContext instead of Robolectric's default context; the receiver test also stops what it starts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- ...gEventPreSerializationTransformerTest.java | 89 ++++ .../logback/classic/net/ReceiverBaseTest.java | 126 ++++++ .../classic/net/SSLSocketAppenderTest.java | 74 ++++ .../classic/net/SSLSocketReceiverTest.java | 53 ++- .../net/SimpleSSLSocketServerTest.java | 102 +++++ .../net/SimpleSocketServerMainTest.java | 34 ++ .../classic/net/SimpleSocketServerTest.java | 390 ++++++++++++++++++ .../logback/classic/net/SocketNodeTest.java | 292 +++++++++++++ .../classic/net/SocketReceiverTest.java | 284 +++++++++++++ .../net/SyslogAppenderWithoutServerTest.java | 169 ++++++++ .../RemoteAppenderStreamClientTest.java | 129 +++++- .../server/SSLServerSocketAppenderTest.java | 73 ++++ .../server/SSLServerSocketReceiverTest.java | 17 + .../net/server/ServerSocketAppenderTest.java | 73 ++++ .../net/server/ServerSocketReceiverTest.java | 108 +++++ 15 files changed, 1995 insertions(+), 18 deletions(-) create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/net/LoggingEventPreSerializationTransformerTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/net/ReceiverBaseTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/net/SSLSocketAppenderTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSSLSocketServerTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/net/SocketNodeTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/net/SyslogAppenderWithoutServerTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/net/server/SSLServerSocketAppenderTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/net/server/ServerSocketAppenderTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/LoggingEventPreSerializationTransformerTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/LoggingEventPreSerializationTransformerTest.java new file mode 100644 index 000000000..09aa6c3cd --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/LoggingEventPreSerializationTransformerTest.java @@ -0,0 +1,89 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.net; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +import java.io.Serializable; + +import org.junit.Test; +import org.junit.function.ThrowingRunnable; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.classic.spi.LoggingEventVO; + +/** + * Unit tests for {@link LoggingEventPreSerializationTransformer}. + */ +public class LoggingEventPreSerializationTransformerTest { + + private final LoggingEventPreSerializationTransformer pst = + new LoggingEventPreSerializationTransformer(); + + @Test + public void transformsNullToNull() { + assertNull(pst.transform(null)); + } + + @Test + public void transformsLoggingEventToEquivalentValueObject() { + Logger logger = new LoggerContext().getLogger("pst"); + LoggingEvent event = new LoggingEvent(getClass().getName(), logger, Level.WARN, + "hello {}", null, new Object[] {"world"}); + + Serializable transformed = pst.transform(event); + + assertTrue(transformed instanceof LoggingEventVO); + LoggingEventVO vo = (LoggingEventVO) transformed; + assertEquals("pst", vo.getLoggerName()); + assertEquals(Level.WARN, vo.getLevel()); + assertEquals("hello world", vo.getFormattedMessage()); + assertEquals(event.getTimeStamp(), vo.getTimeStamp()); + } + + @Test + public void passesValueObjectThroughUnchanged() { + Logger logger = new LoggerContext().getLogger("pst"); + LoggingEventVO vo = LoggingEventVO.build(new LoggingEvent(getClass().getName(), logger, + Level.INFO, "msg", null, null)); + + assertSame(vo, pst.transform(vo)); + } + + @Test + public void rejectsOtherEventTypes() { + final ILoggingEvent other = mock(ILoggingEvent.class); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + new ThrowingRunnable() { + @Override + public void run() { + pst.transform(other); + } + }); + + assertEquals("Unsupported type " + other.getClass().getName(), ex.getMessage()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/ReceiverBaseTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/ReceiverBaseTest.java new file mode 100644 index 000000000..9262d390e --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/ReceiverBaseTest.java @@ -0,0 +1,126 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.net; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; +import org.junit.function.ThrowingRunnable; + +import ch.qos.logback.core.net.mock.MockContext; +import ch.qos.logback.core.net.server.MockScheduledExecutorService; +import ch.qos.logback.core.status.ErrorStatus; +import ch.qos.logback.core.status.Status; + +/** + * Unit tests for {@link ReceiverBase}. + */ +public class ReceiverBaseTest { + + private final MockScheduledExecutorService executor = new MockScheduledExecutorService(); + private final MockContext context = new MockContext(executor); + private final TestReceiver receiver = new TestReceiver(); + + @Test + public void startWithoutContextFails() { + IllegalStateException ex = assertThrows(IllegalStateException.class, new ThrowingRunnable() { + @Override + public void run() { + receiver.start(); + } + }); + + assertEquals("context not set", ex.getMessage()); + assertFalse(receiver.isStarted()); + } + + @Test + public void startRunsTheTaskOnlyWhenTheReceiverShouldStart() { + receiver.setContext(context); + receiver.shouldStart = false; + receiver.start(); + assertFalse(receiver.isStarted()); + assertEquals(0, receiver.taskRuns); + + receiver.shouldStart = true; + receiver.start(); + assertTrue(receiver.isStarted()); + assertSame(receiver.task, executor.getLastCommand()); + assertEquals(1, receiver.taskRuns); + + // starting again is a no-op + receiver.start(); + assertEquals(1, receiver.taskRuns); + } + + @Test + public void stopReportsFailureOfOnStopAndStillStops() { + receiver.setContext(context); + receiver.start(); + RuntimeException failure = new IllegalStateException("cannot stop"); + receiver.onStopFailure = failure; + + receiver.stop(); + + assertFalse(receiver.isStarted()); + Status status = context.getLastStatus(); + assertTrue(status instanceof ErrorStatus); + assertEquals("on stop: " + failure, status.getMessage()); + assertSame(failure, status.getThrowable()); + } + + @Test + public void stopWhenNotStartedDoesNotCallOnStop() { + receiver.setContext(context); + receiver.stop(); + assertEquals(0, receiver.onStopCount); + } + + private static class TestReceiver extends ReceiverBase { + int taskRuns; + final Runnable task = new Runnable() { + @Override + public void run() { + taskRuns++; + } + }; + boolean shouldStart = true; + RuntimeException onStopFailure; + int onStopCount; + + @Override + protected boolean shouldStart() { + return shouldStart; + } + + @Override + protected void onStop() { + onStopCount++; + if (onStopFailure != null) { + throw onStopFailure; + } + } + + @Override + protected Runnable getRunnableTask() { + return task; + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/SSLSocketAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/SSLSocketAppenderTest.java new file mode 100644 index 000000000..eb506b992 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/SSLSocketAppenderTest.java @@ -0,0 +1,74 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.net; + +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import org.junit.Test; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.classic.spi.LoggingEventVO; +import ch.qos.logback.core.spi.PreSerializationTransformer; + +/** + * Unit tests for {@link SSLSocketAppender}. + */ +public class SSLSocketAppenderTest { + + private final SSLSocketAppender appender = new SSLSocketAppender(); + + @Test + public void callerDataIsNotComputedByDefault() { + ILoggingEvent event = mock(ILoggingEvent.class); + appender.postProcessEvent(event); + verify(event, never()).getCallerData(); + } + + @Test + public void callerDataIsComputedWhenIncluded() { + ILoggingEvent event = mock(ILoggingEvent.class); + appender.setIncludeCallerData(true); + appender.postProcessEvent(event); + verify(event).getCallerData(); + } + + @Test + public void callerDataIsNotComputedWhenExcludedAgain() { + ILoggingEvent event = mock(ILoggingEvent.class); + appender.setIncludeCallerData(true); + appender.setIncludeCallerData(false); + appender.postProcessEvent(event); + verify(event, never()).getCallerData(); + } + + @Test + public void transformerSerializesLoggingEventsAsValueObjects() { + PreSerializationTransformer pst = appender.getPST(); + assertTrue(pst instanceof LoggingEventPreSerializationTransformer); + assertSame(pst, appender.getPST()); + + LoggingEvent event = new LoggingEvent(getClass().getName(), + new LoggerContext().getLogger("ssl"), Level.INFO, "msg", null, null); + assertTrue(pst.transform(event) instanceof LoggingEventVO); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/SSLSocketReceiverTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/SSLSocketReceiverTest.java index b99639f68..90bea0bf3 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/net/SSLSocketReceiverTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/SSLSocketReceiverTest.java @@ -15,35 +15,51 @@ */ package ch.qos.logback.classic.net; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import java.net.InetAddress; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import org.junit.After; import org.junit.Before; import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.slf4j.LoggerFactory; import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.core.net.ssl.SSLConfiguration; +import ch.qos.logback.core.spi.ContextAware; +import ch.qos.logback.core.status.Status; /** * Unit tests for {@link SSLSocketReceiver}. * * @author Carl Harris */ -@RunWith(RobolectricTestRunner.class) public class SSLSocketReceiverTest { + private final LoggerContext lc = new LoggerContext(); + private SSLSocketReceiver remote = new SSLSocketReceiver(); @Before public void setUp() throws Exception { - LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory(); remote.setContext(lc); } + @After + public void tearDown() { + remote.stop(); + lc.stop(); + } + @Test public void testUsingDefaultConfig() throws Exception { // should be able to start successfully with no SSL configuration at all @@ -52,4 +68,31 @@ public void testUsingDefaultConfig() throws Exception { remote.start(); assertNotNull(remote.getSocketFactory()); } + + @Test + public void startFailsWhenTheSslContextCannotBeCreated() throws Exception { + NoSuchAlgorithmException failure = new NoSuchAlgorithmException("no TLS here"); + SSLConfiguration ssl = mock(SSLConfiguration.class); + when(ssl.createContext(any(ContextAware.class))).thenThrow(failure); + remote.setSsl(ssl); + remote.setRemoteHost("127.0.0.1"); + remote.setPort(6000); + + remote.start(); + + assertFalse(remote.isStarted()); + assertNull(remote.getSocketFactory()); + List statuses = lc.getStatusManager().getCopyOfStatusList(); + Status last = statuses.get(statuses.size() - 1); + assertEquals(Status.ERROR, last.getLevel()); + assertEquals("no TLS here", last.getMessage()); + assertSame(failure, last.getThrowable()); + } + + @Test + public void sslConfigurationCanBeReplaced() { + SSLConfiguration ssl = new SSLConfiguration(); + remote.setSsl(ssl); + assertSame(ssl, remote.getSsl()); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSSLSocketServerTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSSLSocketServerTest.java new file mode 100644 index 000000000..1ad865d92 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSSLSocketServerTest.java @@ -0,0 +1,102 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.net; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.net.InetAddress; +import java.net.ServerSocket; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLServerSocketFactory; + +import org.junit.After; +import org.junit.Test; +import org.junit.function.ThrowingRunnable; + +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; + +/** + * Unit tests for {@link SimpleSSLSocketServer}. + *

+ * Its {@code main} is tested in {@link SimpleSocketServerMainTest}. + */ +public class SimpleSSLSocketServerTest { + + private final LoggerContext lc = new LoggerContext(); + + @After + public void tearDown() { + lc.stop(); + } + + @Test + public void constructorRejectsMissingSslContext() { + NullPointerException ex = assertThrows(NullPointerException.class, new ThrowingRunnable() { + @Override + public void run() { + new SimpleSSLSocketServer(lc, 0, null); + } + }); + assertEquals("SSL context required", ex.getMessage()); + } + + @Test + public void serverSocketsComeFromTheGivenSslContext() throws Exception { + InetAddress loopback = InetAddress.getLoopbackAddress(); + SSLServerSocket socket = mock(SSLServerSocket.class); + when(socket.getSupportedProtocols()).thenReturn(new String[] {"TLSv1.2"}); + when(socket.getEnabledProtocols()).thenReturn(new String[] {"TLSv1.2"}); + when(socket.getSupportedCipherSuites()).thenReturn(new String[] {"CIPHER"}); + when(socket.getEnabledCipherSuites()).thenReturn(new String[] {"CIPHER"}); + SSLServerSocketFactory sslFactory = mock(SSLServerSocketFactory.class); + when(sslFactory.createServerSocket(4560, 7, loopback)).thenReturn(socket); + SSLContext sslContext = mock(SSLContext.class); + when(sslContext.getServerSocketFactory()).thenReturn(sslFactory); + + SimpleSSLSocketServer server = new SimpleSSLSocketServer(lc, 4560, sslContext); + ServerSocket created = server.getServerSocketFactory().createServerSocket(4560, 7, loopback); + + assertSame(socket, created); + // the server's SSL parameters were applied to the socket + verify(socket).setEnabledProtocols(new String[] {"TLSv1.2"}); + verify(socket).setEnabledCipherSuites(new String[] {"CIPHER"}); + new StatusChecker(lc).assertContainsMatch(Status.INFO, "enabled protocol: TLSv1.2"); + } + + @Test + public void defaultSslContextServesSslServerSockets() throws Exception { + SimpleSSLSocketServer server = new SimpleSSLSocketServer(lc, 0); + + ServerSocket socket = server.getServerSocketFactory() + .createServerSocket(0, 1, InetAddress.getLoopbackAddress()); + try { + assertTrue(socket instanceof SSLServerSocket); + new StatusChecker(lc).assertContainsMatch(Status.INFO, "hostnameVerification=false"); + } finally { + socket.close(); + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerMainTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerMainTest.java index 31184550d..0b35afe43 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerMainTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerMainTest.java @@ -18,6 +18,8 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import java.io.File; import java.io.FileOutputStream; @@ -39,6 +41,7 @@ import org.robolectric.RobolectricTestRunner; import org.slf4j.LoggerFactory; +import ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.spi.ILoggingEvent; @@ -76,6 +79,21 @@ public void tearDown() throws Exception { new ContextInitializer(defaultContext).autoConfig(); } + @Test + public void mainConfiguresTheDefaultContextAndStartsASocketServerOnThePort() throws Exception { + SimpleSocketServer.main(new String[] {OUT_OF_RANGE_PORT, writeCapturingConfig()}); + + CapturingAppender capture = CapturingAppender.from(defaultContext); + assertNotNull("main did not apply the config file", capture); + ILoggingEvent failure = capture.awaitMessage("Unexpected failure in run method"); + capture.joinLoggingThreads(); + + assertNotNull(failure); + assertEquals("Logback SimpleSocketServer (port " + OUT_OF_RANGE_PORT + ")", failure.getThreadName()); + assertEquals(IllegalArgumentException.class.getName(), failure.getThrowableProxy().getClassName()); + assertTrue(capture.hasMessage("Listening on port " + OUT_OF_RANGE_PORT)); + } + @Test public void sslMainStartsAnSslSocketServer() throws Exception { SimpleSSLSocketServer.main(new String[] {OUT_OF_RANGE_PORT, writeCapturingConfig()}); @@ -89,6 +107,22 @@ public void sslMainStartsAnSslSocketServer() throws Exception { assertEquals("Logback SimpleSSLSocketServer (port " + OUT_OF_RANGE_PORT + ")", failure.getThreadName()); } + @Test + public void configureLCResetsTheContextAndAppliesTheConfigFile() throws Exception { + LoggerContext lc = new LoggerContext(); + Logger stale = lc.getLogger("stale"); + stale.setLevel(Level.ERROR); + try { + SimpleSocketServer.configureLC(lc, writeCapturingConfig()); + + assertNull(stale.getLevel()); + assertNotNull(CapturingAppender.from(lc)); + assertEquals(Level.DEBUG, lc.getLogger(Logger.ROOT_LOGGER_NAME).getLevel()); + } finally { + lc.stop(); + } + } + /** * Writes a configuration that sends every event of the context to a * {@link CapturingAppender} named {@code CAPTURE}. diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerTest.java new file mode 100644 index 000000000..035f7f3b7 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerTest.java @@ -0,0 +1,390 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.net; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectOutputStream; +import java.io.PrintStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; + +import javax.net.ServerSocketFactory; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.function.ThrowingRunnable; +import org.mockito.MockedStatic; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.net.mock.MockAppender; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.classic.spi.LoggingEventVO; +import ch.qos.logback.core.read.ListAppender; +import ch.qos.logback.core.util.CloseUtil; + +/** + * Unit tests for {@link SimpleSocketServer}. + *

+ * {@code main} with valid arguments configures a context from XML, which needs + * the Android XML parser; it is tested in {@link SimpleSocketServerMainTest}. + */ +public class SimpleSocketServerTest { + + private static final String SERVER_LOGGER = "simple-socket-server"; + private static final long TIMEOUT_MILLIS = 10000; + + private final LoggerContext lc = new LoggerContext(); + private final ListAppender serverLog = new ListAppender(); + private final List createdSockets = new ArrayList(); + private final String testThreadName = Thread.currentThread().getName(); + private SimpleSocketServer server; + + @Before + public void setUp() { + serverLog.setContext(lc); + serverLog.start(); + Logger log = lc.getLogger(SERVER_LOGGER); + log.setLevel(Level.DEBUG); + log.addAppender(serverLog); + } + + @After + public void tearDown() { + if (server != null) { + server.close(); + } + for (ServerSocket socket : createdSockets) { + CloseUtil.closeQuietly(socket); + } + Thread.currentThread().setName(testThreadName); + lc.stop(); + } + + @Test + public void serverDispatchesClientEventsAndStopsQuietlyWhenClosed() throws Exception { + MockAppender serverEvents = new MockAppender(); + serverEvents.start(); + lc.getLogger(SERVER_LOGGER).addAppender(serverEvents); + MockAppender remoteEvents = new MockAppender(); + remoteEvents.start(); + Logger remoteLogger = lc.getLogger("remote"); + remoteLogger.addAppender(remoteEvents); + + server = newServer(new LoopbackServerSocketFactory(false)); + server.start(); + assertTrue(awaitMessage(serverEvents, "Waiting to accept a new client.")); + + Socket client = new Socket(InetAddress.getLoopbackAddress(), createdSockets.get(0).getLocalPort()); + try { + ObjectOutputStream oos = new ObjectOutputStream(client.getOutputStream()); + oos.writeObject(LoggingEventVO.build(new LoggingEvent(getClass().getName(), remoteLogger, + Level.INFO, "from the client", null, null))); + oos.flush(); + + ILoggingEvent received = remoteEvents.awaitAppend(TIMEOUT_MILLIS); + assertNotNull(received); + assertEquals("from the client", received.getMessage()); + assertEquals("remote", received.getLoggerName()); + // the server is back to accepting once the client's node is running + assertTrue(awaitMessage(serverEvents, "Waiting to accept a new client.")); + + server.close(); + server.join(TIMEOUT_MILLIS); + } finally { + client.close(); + } + + assertFalse(server.isAlive()); + assertTrue(containsMessage(Level.INFO, "Starting new socket node.")); + assertTrue(containsMessage(Level.INFO, + "Exception in run method for a closed server. This is normal.")); + assertTrue(eventsAt(Level.ERROR).isEmpty()); + } + + @Test + public void runDoesNotAcceptClientsWhenClosedBeforeItStarts() throws Exception { + server = newServer(new LoopbackServerSocketFactory(false)); + server.close(); + + server.run(); + + assertTrue(containsMessage(Level.INFO, "Listening on port 0")); + assertFalse(containsMessage(Level.INFO, "Waiting to accept a new client.")); + assertEquals(1, createdSockets.size()); + assertEquals(testThreadName, Thread.currentThread().getName()); + } + + @Test + public void closeClosesTheServerSocketOpenedByRun() throws Exception { + server = newServer(new LoopbackServerSocketFactory(false)); + server.close(); + server.run(); + assertFalse(createdSockets.get(0).isClosed()); + + server.close(); + + assertTrue(createdSockets.get(0).isClosed()); + assertTrue(server.isClosed()); + assertTrue(eventsAt(Level.ERROR).isEmpty()); + } + + @Test + public void closeLogsErrorWhenServerSocketFailsToClose() throws Exception { + server = newServer(new LoopbackServerSocketFactory(true)); + server.close(); + server.run(); + + server.close(); + // the socket that failed to close is dropped, so closing again does not retry it + server.close(); + + List errors = eventsAt(Level.ERROR); + assertEquals(1, errors.size()); + assertEquals("Failed to close serverSocket", errors.get(0).getMessage()); + assertEquals("close failed", errors.get(0).getThrowableProxy().getMessage()); + assertTrue(createdSockets.get(0).isClosed()); + } + + @Test + public void runLogsUnexpectedFailureWhenServerSocketCannotBeCreated() { + server = newServer(new ServerSocketFactory() { + @Override + public ServerSocket createServerSocket(int port) throws IOException { + throw new IOException("cannot listen"); + } + + @Override + public ServerSocket createServerSocket(int port, int backlog) { + throw new UnsupportedOperationException(); + } + + @Override + public ServerSocket createServerSocket(int port, int backlog, InetAddress address) { + throw new UnsupportedOperationException(); + } + }); + + server.run(); + + List errors = eventsAt(Level.ERROR); + assertEquals(1, errors.size()); + assertEquals("Unexpected failure in run method", errors.get(0).getMessage()); + assertEquals("cannot listen", errors.get(0).getThrowableProxy().getMessage()); + assertFalse(containsMessage(Level.INFO, "Exception in run method for a closed server. This is normal.")); + assertFalse(server.isClosed()); + assertEquals(testThreadName, Thread.currentThread().getName()); + } + + @Test + public void isClosedIsFalseUntilCloseIsCalled() { + server = new SimpleSocketServer(lc, 0); + assertFalse(server.isClosed()); + server.close(); + assertTrue(server.isClosed()); + } + + @Test + public void signalAlmostReadinessCountsDownOnlyAPendingLatch() { + server = new SimpleSocketServer(lc, 0); + // without a latch there is nothing to signal + server.signalAlmostReadiness(); + + CountDownLatch latch = new CountDownLatch(1); + server.setLatch(latch); + assertSame(latch, server.getLatch()); + + server.signalAlmostReadiness(); + assertEquals(0, latch.getCount()); + server.signalAlmostReadiness(); + assertEquals(0, latch.getCount()); + } + + @Test + public void parsePortNumberParsesADecimalPort() { + assertEquals(4560, SimpleSocketServer.parsePortNumber("4560")); + } + + @Test + public void parsePortNumberPrintsUsageAndExitsOnUnparsablePort() { + Runtime runtime = mock(Runtime.class); + String stderr; + int port; + try (MockedStatic runtimeStatic = mockStatic(Runtime.class)) { + runtimeStatic.when(Runtime::getRuntime).thenReturn(runtime); + StderrCapture capture = new StderrCapture(); + try { + port = SimpleSocketServer.parsePortNumber("not-a-port"); + } finally { + stderr = capture.stop(); + } + } + + verify(runtime).exit(1); + // only reached because the mocked exit returns + assertEquals(-1, port); + assertTrue(stderr, stderr.contains(NumberFormatException.class.getName())); + assertTrue(stderr, stderr.contains("Could not interpret port number [not-a-port].")); + assertTrue(stderr, stderr.contains( + "Usage: java " + SimpleSocketServer.class.getName() + " port configFile")); + } + + @Test + public void mainPrintsUsageAndExitsOnWrongNumberOfArguments() { + Runtime runtime = mock(Runtime.class); + String stderr; + try (MockedStatic runtimeStatic = mockStatic(Runtime.class)) { + runtimeStatic.when(Runtime::getRuntime).thenReturn(runtime); + StderrCapture capture = new StderrCapture(); + try { + // the mocked exit returns, so main runs on and trips over the missing + // config file argument + assertThrows(ArrayIndexOutOfBoundsException.class, new ThrowingRunnable() { + @Override + public void run() throws Throwable { + SimpleSocketServer.main(new String[] {"4560"}); + } + }); + } finally { + stderr = capture.stop(); + } + } + + verify(runtime).exit(1); + assertEquals("Wrong number of arguments." + System.lineSeparator() + + "Usage: java " + SimpleSocketServer.class.getName() + " port configFile" + + System.lineSeparator(), stderr); + } + + private SimpleSocketServer newServer(final ServerSocketFactory factory) { + SimpleSocketServer server = new SimpleSocketServer(lc, 0) { + @Override + protected ServerSocketFactory getServerSocketFactory() { + return factory; + } + }; + server.logger = lc.getLogger(SERVER_LOGGER); + return server; + } + + private static boolean awaitMessage(MockAppender appender, String message) + throws InterruptedException { + ILoggingEvent event; + while ((event = appender.awaitAppend(TIMEOUT_MILLIS)) != null) { + if (message.equals(event.getFormattedMessage())) { + return true; + } + } + return false; + } + + private boolean containsMessage(Level level, String message) { + for (ILoggingEvent event : eventsAt(level)) { + if (message.equals(event.getFormattedMessage())) { + return true; + } + } + return false; + } + + private List eventsAt(Level level) { + List events = new ArrayList(); + for (ILoggingEvent event : serverLog.list) { + if (event.getLevel() == level) { + events.add(event); + } + } + return events; + } + + /** + * Creates loopback server sockets on an ephemeral port and remembers them; + * optionally the sockets fail to close (after actually closing). + */ + private final class LoopbackServerSocketFactory extends ServerSocketFactory { + private final boolean failOnClose; + + LoopbackServerSocketFactory(boolean failOnClose) { + this.failOnClose = failOnClose; + } + + @Override + public ServerSocket createServerSocket(int port) throws IOException { + ServerSocket socket = new ServerSocket(port, 1, InetAddress.getLoopbackAddress()) { + @Override + public void close() throws IOException { + super.close(); + if (failOnClose) { + throw new IOException("close failed"); + } + } + }; + createdSockets.add(socket); + return socket; + } + + @Override + public ServerSocket createServerSocket(int port, int backlog) { + throw new UnsupportedOperationException(); + } + + @Override + public ServerSocket createServerSocket(int port, int backlog, InetAddress address) { + throw new UnsupportedOperationException(); + } + } + + /** Captures what is printed to {@link System#err} until {@link #stop()}. */ + private static final class StderrCapture { + private final PrintStream original = System.err; + private final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + StderrCapture() { + try { + System.setErr(new PrintStream(bytes, true, "UTF-8")); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + String stop() { + System.setErr(original); + try { + return bytes.toString("UTF-8"); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/SocketNodeTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/SocketNodeTest.java new file mode 100644 index 000000000..fcb759d01 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/SocketNodeTest.java @@ -0,0 +1,292 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.net; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.ObjectOutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketAddress; +import java.net.SocketException; +import java.util.ArrayList; +import java.util.List; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.net.server.HardenedLoggingEventInputStream; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.classic.spi.LoggingEventVO; +import ch.qos.logback.core.read.ListAppender; + +/** + * Unit tests for {@link SocketNode}. The node reads from a fake socket on the + * test thread, so every outcome is deterministic. + */ +public class SocketNodeTest { + + private static final SocketAddress PEER = new InetSocketAddress(InetAddress.getLoopbackAddress(), 4321); + + private final LoggerContext lc = new LoggerContext(); + private final ListAppender nodeLog = new ListAppender(); + private final ListAppender remoteEvents = new ListAppender(); + private final List closingNodes = new ArrayList(); + private final SimpleSocketServer server = new SimpleSocketServer(lc, 0) { + @Override + public void socketNodeClosing(SocketNode sn) { + closingNodes.add(sn); + } + }; + private Logger remoteLogger; + + @Before + public void setUp() { + nodeLog.setContext(lc); + nodeLog.start(); + lc.getLogger(SocketNode.class).addAppender(nodeLog); + remoteEvents.setContext(lc); + remoteEvents.start(); + remoteLogger = lc.getLogger("remote"); + remoteLogger.addAppender(remoteEvents); + } + + @After + public void tearDown() { + lc.stop(); + } + + @Test + public void runDispatchesOnlyEventsEnabledForTheLocalLogger() throws Exception { + remoteLogger.setLevel(Level.INFO); + FakeSocket socket = new FakeSocket(new ScriptedInputStream( + serialize(event(Level.DEBUG, "dropped"), event(Level.WARN, "kept")), null)); + SocketNode node = new SocketNode(server, socket, lc); + + node.run(); + + assertEquals(1, remoteEvents.list.size()); + assertEquals("kept", remoteEvents.list.get(0).getMessage()); + assertEquals(Level.WARN, remoteEvents.list.get(0).getLevel()); + assertLogged(Level.INFO, "Caught java.io.EOFException closing connection."); + assertNodeClosed(node, socket); + } + + @Test + public void runLogsErrorAndClosesWhenTheInputStreamCannotBeOpened() { + final IOException failure = new IOException("no stream"); + FakeSocket socket = new FakeSocket(null) { + @Override + public InputStream getInputStream() throws IOException { + throw failure; + } + }; + SocketNode node = new SocketNode(server, socket, lc); + + node.run(); + + ILoggingEvent error = assertLogged(Level.ERROR, "Could not open ObjectInputStream to " + socket); + assertEquals("no stream", error.getThrowableProxy().getMessage()); + assertEquals(1, nodeLog.list.size()); + assertTrue(remoteEvents.list.isEmpty()); + assertTrue(node.closed); + assertNull(node.hardenedLoggingEventInputStream); + assertEquals(1, closingNodes.size()); + assertSame(node, closingNodes.get(0)); + } + + @Test + public void runLogsSocketExceptionAndCloses() throws Exception { + FakeSocket socket = new FakeSocket(new ScriptedInputStream(serialize(), + new SocketException("connection reset"))); + SocketNode node = new SocketNode(server, socket, lc); + + node.run(); + + assertLogged(Level.INFO, "Caught java.net.SocketException closing connection."); + assertNodeClosed(node, socket); + } + + @Test + public void runLogsOtherIOExceptionAndCloses() throws Exception { + FakeSocket socket = new FakeSocket(new ScriptedInputStream(serialize(), + new IOException("broken pipe"))); + SocketNode node = new SocketNode(server, socket, lc); + + node.run(); + + assertLogged(Level.INFO, "Caught java.io.IOException: java.io.IOException: broken pipe"); + assertLogged(Level.INFO, "Closing connection."); + assertNodeClosed(node, socket); + } + + @Test + public void runLogsUnexpectedExceptionForAnObjectThatIsNotAnEvent() throws Exception { + FakeSocket socket = new FakeSocket(new ScriptedInputStream(serialize("not an event"), null)); + SocketNode node = new SocketNode(server, socket, lc); + + node.run(); + + ILoggingEvent error = assertLogged(Level.ERROR, "Unexpected exception. Closing connection."); + assertEquals(ClassCastException.class.getName(), error.getThrowableProxy().getClassName()); + assertTrue(remoteEvents.list.isEmpty()); + assertNodeClosed(node, socket); + } + + @Test + public void closeWarnsWhenTheInputStreamFailsToClose() throws Exception { + ScriptedInputStream in = new ScriptedInputStream(serialize(), null); + in.closeFailure = new IOException("cannot close"); + SocketNode node = new SocketNode(server, new FakeSocket(null), lc); + node.hardenedLoggingEventInputStream = new HardenedLoggingEventInputStream(in); + + node.close(); + + ILoggingEvent warning = assertLogged(Level.WARN, "Could not close connection."); + assertEquals("cannot close", warning.getThrowableProxy().getMessage()); + assertTrue(node.closed); + assertNull(node.hardenedLoggingEventInputStream); + } + + @Test + public void closeBeforeRunOnlyMarksTheNodeClosed() { + SocketNode node = new SocketNode(server, new FakeSocket(null), lc); + + node.close(); + node.close(); + + assertTrue(node.closed); + assertTrue(nodeLog.list.isEmpty()); + } + + @Test + public void toStringNamesTheRemotePeer() { + SocketNode node = new SocketNode(server, new FakeSocket(null), lc); + assertEquals(SocketNode.class.getName() + PEER, node.toString()); + } + + private void assertNodeClosed(SocketNode node, FakeSocket socket) { + assertTrue(node.closed); + assertNull(node.hardenedLoggingEventInputStream); + assertTrue("socket input stream was not closed", socket.in.closed); + assertEquals(1, closingNodes.size()); + assertSame(node, closingNodes.get(0)); + for (ILoggingEvent event : nodeLog.list) { + assertFalse(event.getFormattedMessage(), event.getLevel() == Level.WARN); + } + } + + private ILoggingEvent assertLogged(Level level, String message) { + for (ILoggingEvent event : nodeLog.list) { + if (event.getLevel() == level && message.equals(event.getFormattedMessage())) { + return event; + } + } + throw new AssertionError("no " + level + " [" + message + "] in " + nodeLog.list); + } + + private LoggingEventVO event(Level level, String message) { + return LoggingEventVO.build(new LoggingEvent(getClass().getName(), remoteLogger, level, + message, null, null)); + } + + private static byte[] serialize(Object... objects) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bytes); + for (Object object : objects) { + oos.writeObject(object); + } + oos.close(); + return bytes.toByteArray(); + } + + /** + * Serves the given bytes, then either signals end-of-stream or throws the + * given failure; can also fail to close. + */ + private static class ScriptedInputStream extends InputStream { + private final byte[] data; + private final IOException failure; + private int pos; + IOException closeFailure; + boolean closed; + + ScriptedInputStream(byte[] data, IOException failure) { + this.data = data; + this.failure = failure; + } + + @Override + public int read() throws IOException { + byte[] one = new byte[1]; + return read(one, 0, 1) == -1 ? -1 : one[0] & 0xff; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + if (pos == data.length) { + if (failure != null) { + throw failure; + } + return -1; + } + int n = Math.min(len, data.length - pos); + System.arraycopy(data, pos, b, off, n); + pos += n; + return n; + } + + @Override + public void close() throws IOException { + closed = true; + if (closeFailure != null) { + throw closeFailure; + } + } + } + + /** An unconnected socket that reports {@link #PEER} and serves a scripted stream. */ + private static class FakeSocket extends Socket { + final ScriptedInputStream in; + + FakeSocket(ScriptedInputStream in) { + this.in = in; + } + + @Override + public InputStream getInputStream() throws IOException { + return in; + } + + @Override + public SocketAddress getRemoteSocketAddress() { + return PEER; + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/SocketReceiverTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/SocketReceiverTest.java index 498333275..fab43d8ce 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/net/SocketReceiverTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/SocketReceiverTest.java @@ -19,14 +19,26 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; import java.io.IOException; +import java.io.InputStream; import java.io.ObjectOutputStream; +import java.io.ObjectStreamConstants; +import java.net.ConnectException; import java.net.InetAddress; import java.net.ServerSocket; import java.net.Socket; import java.net.UnknownHostException; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Deque; +import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; @@ -43,9 +55,12 @@ import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.classic.spi.LoggingEvent; import ch.qos.logback.classic.spi.LoggingEventVO; +import ch.qos.logback.core.net.AbstractSocketAppender; +import ch.qos.logback.core.net.DefaultSocketConnector; import ch.qos.logback.core.net.SocketConnector; import ch.qos.logback.core.net.server.ServerSocketUtil; import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; import ch.qos.logback.core.testUtil.NetworkTestUtil; /** @@ -57,6 +72,10 @@ public class SocketReceiverTest { private static final int DELAY = 1000; private static final String TEST_HOST_NAME = "NOT.A.VALID.HOST.NAME"; + private static final String LOOPBACK_ADDRESS = "127.0.0.1"; + private static final int REMOTE_PORT = 6000; + private static final String RECEIVER_ID = + "receiver " + LOOPBACK_ADDRESS + ":" + REMOTE_PORT + ": "; private ServerSocket serverSocket; @@ -206,6 +225,271 @@ public void testNoDispatchEventForDisabledLevel() throws Exception { assertNull(appender.awaitAppend(DELAY)); } + @Test + public void runEndsWhenTheExecutorRejectsTheConnector() throws Exception { + ScriptedSocketReceiver scripted = newScriptedReceiver(); + lc.getScheduledExecutorService().shutdown(); + + scripted.run(); + + assertEquals(1, scripted.connectors.size()); + assertEquals(0, scripted.connectors.get(0).calls); + assertEquals("shutting down", lastStatus().getMessage()); + } + + @Test + public void runEndsWhenTheConnectorFails() throws Exception { + ScriptedSocketReceiver scripted = newScriptedReceiver(); + + scripted.run(); + + assertEquals(1, scripted.connectors.size()); + assertEquals(1, scripted.connectors.get(0).calls); + assertEquals("shutting down", lastStatus().getMessage()); + new StatusChecker(lc).assertNoMatch("connection established"); + } + + @Test + public void dispatchReportsEndOfStreamThenReconnects() throws Exception { + logger.setLevel(Level.DEBUG); + FakeSocket peer = new FakeSocket(serialize(LoggingEventVO.build(new LoggingEvent( + logger.getName(), logger, Level.INFO, "over the wire", null, null)))); + ScriptedSocketReceiver scripted = newScriptedReceiver(peer); + scripted.setAcceptConnectionTimeout(1234); + + scripted.run(); + + ILoggingEvent received = appender.getLastEvent(); + assertEquals("over the wire", received.getMessage()); + assertEquals(Arrays.asList(1234, 0), peer.soTimeouts); + assertTrue(peer.isClosed()); + StatusChecker checker = new StatusChecker(lc); + checker.assertContainsMatch(Status.INFO, "^" + RECEIVER_ID + "connection established$"); + checker.assertContainsMatch(Status.INFO, "^" + RECEIVER_ID + "end-of-stream detected$"); + checker.assertContainsMatch(Status.INFO, "^" + RECEIVER_ID + "connection closed$"); + // once the connection closed, the receiver connected again (which failed) + assertEquals(2, scripted.connectors.size()); + assertEquals("shutting down", lastStatus().getMessage()); + } + + @Test + public void dispatchReportsUnknownEventClass() throws Exception { + FakeSocket peer = new FakeSocket(serializedObjectOfMissingClass("does.not.Exist")); + ScriptedSocketReceiver scripted = newScriptedReceiver(peer); + + scripted.run(); + + assertNull(appender.getLastEvent()); + assertTrue(peer.isClosed()); + StatusChecker checker = new StatusChecker(lc); + checker.assertContainsMatch(Status.INFO, "^" + RECEIVER_ID + + "unknown event class: java.lang.ClassNotFoundException: does.not.Exist$"); + checker.assertContainsMatch(Status.INFO, "^" + RECEIVER_ID + "connection closed$"); + } + + @Test + public void connectorsRetryAfterTheDefaultReconnectionDelay() throws Exception { + ScriptedSocketReceiver scripted = newScriptedReceiver(); + + scripted.run(); + + assertEquals(Arrays.asList(AbstractSocketAppender.DEFAULT_RECONNECTION_DELAY), + scripted.retryDelays); + } + + @Test + public void connectorsRetryAfterTheConfiguredReconnectionDelay() throws Exception { + ScriptedSocketReceiver scripted = new ScriptedSocketReceiver(); + scripted.setReconnectionDelay(1234); + configure(scripted); + + scripted.run(); + + assertEquals(Arrays.asList(1234), scripted.retryDelays); + } + + @Test + public void connectorsUseThePlatformSocketFactoryAndReportToTheReceiver() throws Exception { + ScriptedSocketReceiver scripted = newScriptedReceiver(); + + scripted.run(); + + ScriptedConnector used = scripted.connectors.get(0); + assertSame(SocketFactory.getDefault(), used.socketFactory); + assertSame(scripted, used.exceptionHandler); + } + + @Test + public void defaultConnectorConnectsToTheRemoteServer() throws Exception { + SocketConnector defaultConnector = new SocketReceiver().newConnector( + serverSocket.getInetAddress(), serverSocket.getLocalPort(), 0, DELAY); + assertTrue(defaultConnector instanceof DefaultSocketConnector); + + Socket connected = defaultConnector.call(); + try { + assertTrue(connected.isConnected()); + assertEquals(serverSocket.getLocalPort(), connected.getPort()); + } finally { + connected.close(); + } + } + + @Test + public void connectionFailedReportsInterruption() throws Exception { + ScriptedSocketReceiver scripted = newScriptedReceiver(); + InterruptedException ex = new InterruptedException(); + + scripted.connectionFailed(null, ex); + + assertWarning("connector interrupted", ex); + } + + @Test + public void connectionFailedReportsRefusedConnection() throws Exception { + ScriptedSocketReceiver scripted = newScriptedReceiver(); + ConnectException ex = new ConnectException("refused"); + + scripted.connectionFailed(null, ex); + + assertWarning(RECEIVER_ID + "connection refused", ex); + } + + @Test + public void connectionFailedReportsOtherErrors() throws Exception { + ScriptedSocketReceiver scripted = newScriptedReceiver(); + IOException ex = new IOException("other"); + + scripted.connectionFailed(null, ex); + + assertWarning(RECEIVER_ID + "unspecified error", ex); + } + + private ScriptedSocketReceiver newScriptedReceiver(Socket... sockets) { + ScriptedSocketReceiver scripted = new ScriptedSocketReceiver(); + scripted.sockets.addAll(Arrays.asList(sockets)); + configure(scripted); + return scripted; + } + + private void configure(ScriptedSocketReceiver scripted) { + scripted.setContext(lc); + scripted.setRemoteHost(LOOPBACK_ADDRESS); + scripted.setPort(REMOTE_PORT); + // resolves the address and names the receiver, as start() would + assertTrue(scripted.shouldStart()); + } + + private Status lastStatus() { + List statuses = lc.getStatusManager().getCopyOfStatusList(); + return statuses.get(statuses.size() - 1); + } + + private void assertWarning(String message, Throwable throwable) { + Status status = lastStatus(); + assertEquals(Status.WARN, status.getLevel()); + assertEquals(message, status.getMessage()); + assertSame(throwable, status.getThrowable()); + } + + private static byte[] serialize(Object... objects) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bytes); + for (Object object : objects) { + oos.writeObject(object); + } + oos.close(); + return bytes.toByteArray(); + } + + /** + * A serialization stream holding one object of a proxy class that + * implements a missing interface: reading it fails with + * {@link ClassNotFoundException}. + */ + private static byte[] serializedObjectOfMissingClass(String interfaceName) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bytes); + out.writeShort(ObjectStreamConstants.STREAM_MAGIC); + out.writeShort(ObjectStreamConstants.STREAM_VERSION); + out.writeByte(ObjectStreamConstants.TC_OBJECT); + out.writeByte(ObjectStreamConstants.TC_PROXYCLASSDESC); + out.writeInt(1); + out.writeUTF(interfaceName); + out.writeByte(ObjectStreamConstants.TC_ENDBLOCKDATA); + out.writeByte(ObjectStreamConstants.TC_NULL); + out.close(); + return bytes.toByteArray(); + } + + /** + * A {@link SocketReceiver} whose connectors hand out the given sockets, one + * per connection attempt, and fail once there are none left. + */ + private static class ScriptedSocketReceiver extends SocketReceiver { + final Deque sockets = new ArrayDeque(); + final List connectors = new ArrayList(); + final List retryDelays = new ArrayList(); + + @Override + protected SocketConnector newConnector(InetAddress address, int port, + int initialDelay, int retryDelay) { + retryDelays.add(retryDelay); + ScriptedConnector connector = new ScriptedConnector(sockets.poll()); + connectors.add(connector); + return connector; + } + } + + private static class ScriptedConnector implements SocketConnector { + private final Socket socket; + volatile int calls; + ExceptionHandler exceptionHandler; + SocketFactory socketFactory; + + ScriptedConnector(Socket socket) { + this.socket = socket; + } + + @Override + public Socket call() { + calls++; + if (socket == null) { + throw new IllegalStateException("no more connections"); + } + return socket; + } + + @Override + public void setExceptionHandler(ExceptionHandler exceptionHandler) { + this.exceptionHandler = exceptionHandler; + } + + @Override + public void setSocketFactory(SocketFactory socketFactory) { + this.socketFactory = socketFactory; + } + } + + /** An unconnected socket that serves the given bytes, then end-of-stream. */ + private static class FakeSocket extends Socket { + private final InputStream in; + final List soTimeouts = new ArrayList(); + + FakeSocket(byte[] data) { + this.in = new ByteArrayInputStream(data); + } + + @Override + public InputStream getInputStream() { + return in; + } + + @Override + public void setSoTimeout(int timeout) { + soTimeouts.add(timeout); + } + } + /** * A {@link SocketReceiver} with instrumentation for unit testing. */ diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/SyslogAppenderWithoutServerTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/SyslogAppenderWithoutServerTest.java new file mode 100644 index 000000000..bb3e203fe --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/SyslogAppenderWithoutServerTest.java @@ -0,0 +1,169 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.net; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.Test; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.net.SyslogConstants; + +/** + * Unit tests for the parts of {@link SyslogAppender} that need neither a + * syslog server nor Robolectric: severity mapping and how stack traces are + * written to the syslog stream. The stream is a recording fake, and the + * appender is not started, so the stack trace lines carry no syslog prefix. + *

+ * See {@link SyslogAppenderTest} for tests against a (mock) syslog server. + */ +public class SyslogAppenderWithoutServerTest { + + private final Logger logger = new LoggerContext().getLogger("syslog"); + private final SyslogAppender appender = new SyslogAppender(); + + @Test + public void severityFollowsTheEventLevel() { + assertEquals(SyslogConstants.ERROR_SEVERITY, appender.getSeverityForEvent(event(Level.ERROR, null))); + assertEquals(SyslogConstants.WARNING_SEVERITY, appender.getSeverityForEvent(event(Level.WARN, null))); + assertEquals(SyslogConstants.INFO_SEVERITY, appender.getSeverityForEvent(event(Level.INFO, null))); + assertEquals(SyslogConstants.DEBUG_SEVERITY, appender.getSeverityForEvent(event(Level.DEBUG, null))); + assertEquals(SyslogConstants.DEBUG_SEVERITY, appender.getSeverityForEvent(event(Level.TRACE, null))); + } + + @Test + public void postProcessWritesEachLineOfTheThrowableAndItsCause() { + RecordingOutputStream out = new RecordingOutputStream(-1); + + appender.postProcess(event(Level.ERROR, outerWithCause()), out); + + assertEquals(Arrays.asList( + "java.lang.IllegalStateException: outer", + "at a.B.outer(B.java:1)", + CoreConstants.CAUSED_BY + "java.io.IOException: inner", + "at c.D.inner(D.java:2)"), out.writes); + assertEquals(4, out.flushes); + } + + @Test + public void postProcessStopsAtTheFirstWriteFailure() { + RecordingOutputStream out = new RecordingOutputStream(0); + + appender.postProcess(event(Level.ERROR, outerWithCause()), out); + + assertEquals(1, out.attempts); + assertTrue(out.writes.isEmpty()); + } + + @Test + public void postProcessStopsWhenAStackFrameCannotBeWritten() { + RecordingOutputStream out = new RecordingOutputStream(1); + + appender.postProcess(event(Level.ERROR, outerWithCause()), out); + + // the cause is not written once writing the outer exception failed + assertEquals(2, out.attempts); + assertEquals(Arrays.asList("java.lang.IllegalStateException: outer"), out.writes); + } + + @Test + public void postProcessWritesNothingWhenThrowablesAreExcluded() { + assertFalse(appender.isThrowableExcluded()); + appender.setThrowableExcluded(true); + assertTrue(appender.isThrowableExcluded()); + RecordingOutputStream out = new RecordingOutputStream(-1); + + appender.postProcess(event(Level.ERROR, outerWithCause()), out); + + assertEquals(0, out.attempts); + } + + @Test + public void postProcessWritesNothingForAnEventWithoutThrowable() { + RecordingOutputStream out = new RecordingOutputStream(-1); + + appender.postProcess(event(Level.ERROR, null), out); + + assertEquals(0, out.attempts); + } + + @Test + public void stackTracePatternDefaultsToATab() { + assertEquals("\t", appender.getStackTracePattern()); + appender.setStackTracePattern("[%thread] "); + assertEquals("[%thread] ", appender.getStackTracePattern()); + } + + private LoggingEvent event(Level level, Throwable throwable) { + return new LoggingEvent(getClass().getName(), logger, level, "message", throwable, null); + } + + private static Throwable outerWithCause() { + Exception inner = new IOException("inner"); + inner.setStackTrace(new StackTraceElement[] {new StackTraceElement("c.D", "inner", "D.java", 2)}); + Exception outer = new IllegalStateException("outer", inner); + outer.setStackTrace(new StackTraceElement[] {new StackTraceElement("a.B", "outer", "B.java", 1)}); + return outer; + } + + /** + * Records each write as a separate string; fails (with an + * {@link IOException}) from the write with the given index on, or never + * when the index is negative. + */ + private static class RecordingOutputStream extends OutputStream { + private final int failFrom; + final List writes = new ArrayList(); + int attempts; + int flushes; + + RecordingOutputStream(int failFrom) { + this.failFrom = failFrom; + } + + @Override + public void write(int b) throws IOException { + write(new byte[] {(byte) b}, 0, 1); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + int attempt = attempts++; + if (failFrom >= 0 && attempt >= failFrom) { + throw new IOException("write failed"); + } + writes.add(new String(b, off, len, StandardCharsets.UTF_8)); + } + + @Override + public void flush() { + flushes++; + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/server/RemoteAppenderStreamClientTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/server/RemoteAppenderStreamClientTest.java index d63793461..66d1b963e 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/net/server/RemoteAppenderStreamClientTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/server/RemoteAppenderStreamClientTest.java @@ -17,16 +17,20 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStream; import java.io.ObjectOutputStream; +import java.io.ObjectStreamConstants; +import java.io.SequenceInputStream; +import org.junit.After; import org.junit.Before; import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.slf4j.LoggerFactory; import ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; @@ -35,15 +39,19 @@ import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.classic.spi.LoggingEvent; import ch.qos.logback.classic.spi.LoggingEventVO; +import ch.qos.logback.core.read.ListAppender; /** * Unit tests for {@link RemoteAppenderStreamClient}. * * @author Carl Harris */ -@RunWith(RobolectricTestRunner.class) public class RemoteAppenderStreamClientTest { + private static final String CLIENT = "client some client ID"; + + private final LoggerContext lc = new LoggerContext(); + private final ListAppender clientLog = new ListAppender(); private MockAppender appender; private Logger logger; private LoggingEvent event; @@ -51,27 +59,30 @@ public class RemoteAppenderStreamClientTest { @Before public void setUp() throws Exception { - LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory(); - appender = new MockAppender(); appender.start(); logger = lc.getLogger(getClass()); logger.addAppender(appender); + // what the client itself logs, as opposed to the events it receives + clientLog.setContext(lc); + clientLog.start(); + Logger clientLogger = lc.getLogger(RemoteAppenderStreamClient.class.getPackage().getName()); + clientLogger.addAppender(clientLog); + logger.setAdditive(false); + event = new LoggingEvent(logger.getName(), logger, Level.DEBUG, "test message", null, new Object[0]); LoggingEventVO eventVO = LoggingEventVO.build(event); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); - ObjectOutputStream oos = new ObjectOutputStream(bos); - oos.writeObject(eventVO); - oos.close(); + client = newClient(new ByteArrayInputStream(serialize(eventVO))); + } - ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); - client = new RemoteAppenderStreamClient("some client ID", bis); - client.setLoggerContext(lc); + @After + public void tearDown() { + lc.stop(); } @Test @@ -94,4 +105,96 @@ public void testWithDisabledLevel() throws Exception { assertNull(appender.getLastEvent()); } + @Test + public void readFailureIsLoggedAndEndsTheConnection() throws Exception { + final IOException failure = new IOException("connection reset"); + InputStream failing = new SequenceInputStream(new ByteArrayInputStream(serialize()), + new InputStream() { + @Override + public int read() throws IOException { + throw failure; + } + }); + + newClient(failing).run(); + + assertClientLogged(Level.INFO, CLIENT + ": " + failure); + assertClientLogged(Level.INFO, CLIENT + ": connection closed"); + } + + @Test + public void unknownEventClassIsLoggedAndEndsTheConnection() throws Exception { + newClient(new ByteArrayInputStream(serializedObjectOfMissingClass())).run(); + + assertClientLogged(Level.ERROR, CLIENT + ": unknown event class"); + assertClientLogged(Level.INFO, CLIENT + ": connection closed"); + } + + @Test + public void objectThatIsNotAnEventIsLoggedAndEndsTheConnection() throws Exception { + newClient(new ByteArrayInputStream(serialize("not an event"))).run(); + + ILoggingEvent error = assertClientLogged(Level.ERROR, CLIENT + ": java.lang.ClassCastException"); + assertTrue(error.getFormattedMessage().startsWith(CLIENT + ": java.lang.ClassCastException")); + assertNull(appender.getLastEvent()); + assertClientLogged(Level.INFO, CLIENT + ": connection closed"); + } + + @Test + public void streamWithoutHeaderEndsTheConnectionQuietly() throws Exception { + newClient(new ByteArrayInputStream(new byte[0])).run(); + + assertClientLogged(Level.INFO, CLIENT + ": connected"); + assertClientLogged(Level.INFO, CLIENT + ": connection closed"); + assertEquals(2, clientLog.list.size()); + } + + private RemoteAppenderStreamClient newClient(InputStream in) { + RemoteAppenderStreamClient newClient = new RemoteAppenderStreamClient("some client ID", in); + newClient.setLoggerContext(lc); + return newClient; + } + + /** + * Asserts that the client logged a message at the given level that starts + * with the given prefix. + */ + private ILoggingEvent assertClientLogged(Level level, String prefix) { + for (ILoggingEvent logged : clientLog.list) { + if (logged.getLevel() == level && logged.getFormattedMessage().startsWith(prefix)) { + return logged; + } + } + throw new AssertionError("no " + level + " [" + prefix + "...] in " + clientLog.list); + } + + private static byte[] serialize(Object... objects) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + for (Object object : objects) { + oos.writeObject(object); + } + oos.close(); + return bos.toByteArray(); + } + + /** + * A serialization stream holding one object of a proxy class that + * implements a missing interface: reading it fails with + * {@link ClassNotFoundException}. + */ + private static byte[] serializedObjectOfMissingClass() throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bytes); + out.writeShort(ObjectStreamConstants.STREAM_MAGIC); + out.writeShort(ObjectStreamConstants.STREAM_VERSION); + out.writeByte(ObjectStreamConstants.TC_OBJECT); + out.writeByte(ObjectStreamConstants.TC_PROXYCLASSDESC); + out.writeInt(1); + out.writeUTF("does.not.Exist"); + out.writeByte(ObjectStreamConstants.TC_ENDBLOCKDATA); + out.writeByte(ObjectStreamConstants.TC_NULL); + out.close(); + return bytes.toByteArray(); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/server/SSLServerSocketAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/server/SSLServerSocketAppenderTest.java new file mode 100644 index 000000000..6931fb28a --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/server/SSLServerSocketAppenderTest.java @@ -0,0 +1,73 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.net.server; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import org.junit.Test; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.net.LoggingEventPreSerializationTransformer; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.classic.spi.LoggingEventVO; +import ch.qos.logback.core.spi.PreSerializationTransformer; + +/** + * Unit tests for {@link SSLServerSocketAppender}. + */ +public class SSLServerSocketAppenderTest { + + private final SSLServerSocketAppender appender = new SSLServerSocketAppender(); + + @Test + public void callerDataIsExcludedByDefault() { + ILoggingEvent event = mock(ILoggingEvent.class); + + appender.postProcessEvent(event); + + assertFalse(appender.isIncludeCallerData()); + verify(event, never()).getCallerData(); + } + + @Test + public void callerDataIsComputedWhenIncluded() { + ILoggingEvent event = mock(ILoggingEvent.class); + appender.setIncludeCallerData(true); + + appender.postProcessEvent(event); + + assertTrue(appender.isIncludeCallerData()); + verify(event).getCallerData(); + } + + @Test + public void transformerSerializesLoggingEventsAsValueObjects() { + PreSerializationTransformer pst = appender.getPST(); + assertTrue(pst instanceof LoggingEventPreSerializationTransformer); + assertSame(pst, new SSLServerSocketAppender().getPST()); + + LoggingEvent event = new LoggingEvent(getClass().getName(), + new LoggerContext().getLogger("ssl-server"), Level.INFO, "msg", null, null); + assertTrue(pst.transform(event) instanceof LoggingEventVO); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/server/SSLServerSocketReceiverTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/server/SSLServerSocketReceiverTest.java index 6700d1eeb..c03b83c56 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/net/server/SSLServerSocketReceiverTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/server/SSLServerSocketReceiverTest.java @@ -16,6 +16,7 @@ package ch.qos.logback.classic.net.server; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import javax.net.ServerSocketFactory; @@ -24,6 +25,7 @@ import org.junit.Test; import ch.qos.logback.core.net.mock.MockContext; +import ch.qos.logback.core.net.ssl.SSLConfiguration; /** * Unit tests for {@link SSLServerSocketReceiver}. @@ -56,4 +58,19 @@ public void testGetServerSocketFactory() throws Exception { assertTrue(parameters.isContextInjected()); } + @Test + public void serverSocketFactoryIsCreatedOnlyOnce() throws Exception { + ServerSocketFactory first = receiver.getServerSocketFactory(); + assertSame(first, receiver.getServerSocketFactory()); + } + + @Test + public void sslConfigurationDefaultsToANewConfiguration() throws Exception { + SSLServerSocketReceiver unconfigured = new SSLServerSocketReceiver(); + SSLConfiguration ssl = unconfigured.getSsl(); + assertNotNull(ssl); + assertSame(ssl, unconfigured.getSsl()); + assertSame(this.ssl, receiver.getSsl()); + } + } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/server/ServerSocketAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/server/ServerSocketAppenderTest.java new file mode 100644 index 000000000..47bba5d9e --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/server/ServerSocketAppenderTest.java @@ -0,0 +1,73 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.net.server; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import org.junit.Test; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.net.LoggingEventPreSerializationTransformer; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.classic.spi.LoggingEventVO; +import ch.qos.logback.core.spi.PreSerializationTransformer; + +/** + * Unit tests for {@link ServerSocketAppender}. + */ +public class ServerSocketAppenderTest { + + private final ServerSocketAppender appender = new ServerSocketAppender(); + + @Test + public void callerDataIsExcludedByDefault() { + ILoggingEvent event = mock(ILoggingEvent.class); + + appender.postProcessEvent(event); + + assertFalse(appender.isIncludeCallerData()); + verify(event, never()).getCallerData(); + } + + @Test + public void callerDataIsComputedWhenIncluded() { + ILoggingEvent event = mock(ILoggingEvent.class); + appender.setIncludeCallerData(true); + + appender.postProcessEvent(event); + + assertTrue(appender.isIncludeCallerData()); + verify(event).getCallerData(); + } + + @Test + public void transformerSerializesLoggingEventsAsValueObjects() { + PreSerializationTransformer pst = appender.getPST(); + assertTrue(pst instanceof LoggingEventPreSerializationTransformer); + assertSame(pst, new ServerSocketAppender().getPST()); + + LoggingEvent event = new LoggingEvent(getClass().getName(), + new LoggerContext().getLogger("server"), Level.INFO, "msg", null, null); + assertTrue(pst.transform(event) instanceof LoggingEventVO); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/server/ServerSocketReceiverTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/server/ServerSocketReceiverTest.java index c26a4d378..ad413ad98 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/net/server/ServerSocketReceiverTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/server/ServerSocketReceiverTest.java @@ -18,19 +18,30 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import java.io.IOException; +import java.net.InetAddress; import java.net.ServerSocket; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Executor; + +import javax.net.ServerSocketFactory; import org.junit.After; import org.junit.Before; import org.junit.Test; +import ch.qos.logback.core.net.AbstractSocketAppender; import ch.qos.logback.core.net.mock.MockContext; import ch.qos.logback.core.net.server.MockServerListener; import ch.qos.logback.core.net.server.MockServerRunner; +import ch.qos.logback.core.net.server.ServerListener; +import ch.qos.logback.core.net.server.ServerRunner; import ch.qos.logback.core.net.server.ServerSocketUtil; import ch.qos.logback.core.status.ErrorStatus; import ch.qos.logback.core.status.Status; @@ -104,4 +115,101 @@ public void testStopWhenNotStarted() throws Exception { assertEquals(0, runner.getStartCount()); } + @Test + public void startupFailureIsReportedAndClosesTheServerSocket() throws Exception { + final RuntimeException failure = new IllegalStateException("no listener"); + ServerSocketReceiver failing = new InstrumentedServerSocketReceiver(serverSocket, listener, runner) { + @Override + protected ServerListener createServerListener(ServerSocket socket) { + throw failure; + } + }; + failing.setContext(context); + + failing.start(); + + assertFalse(failing.isStarted()); + assertEquals(0, runner.getStartCount()); + assertTrue(serverSocket.isClosed()); + Status status = context.getLastStatus(); + assertTrue(status instanceof ErrorStatus); + assertEquals("server startup error: " + failure, status.getMessage()); + assertSame(failure, status.getThrowable()); + } + + @Test + public void startOpensTheServerSocketOnTheConfiguredPortBacklogAndAddress() throws Exception { + final List socketArgs = new ArrayList(); + ServerSocketReceiver configured = new ServerSocketReceiver() { + @Override + protected ServerSocketFactory getServerSocketFactory() { + return new ServerSocketFactory() { + @Override + public ServerSocket createServerSocket(int port) { + throw new UnsupportedOperationException(); + } + + @Override + public ServerSocket createServerSocket(int port, int backlog) { + throw new UnsupportedOperationException(); + } + + @Override + public ServerSocket createServerSocket(int port, int backlog, InetAddress address) { + socketArgs.add(port); + socketArgs.add(backlog); + socketArgs.add(address); + return serverSocket; + } + }; + } + + @Override + protected ServerRunner createServerRunner(ServerListener listener, + Executor executor) { + return runner; + } + }; + configured.setContext(context); + configured.setPort(1234); + configured.setBacklog(7); + configured.setAddress("127.0.0.1"); + + configured.start(); + + assertTrue(configured.isStarted()); + assertEquals(1234, configured.getPort()); + assertEquals(7, configured.getBacklog()); + assertEquals("127.0.0.1", configured.getAddress()); + assertEquals(Arrays.asList(1234, 7, InetAddress.getByName("127.0.0.1")), socketArgs); + configured.stop(); + } + + @Test + public void defaultsListenOnAllInterfacesOfTheDefaultPort() throws Exception { + ServerSocketReceiver defaults = new ServerSocketReceiver(); + assertEquals(AbstractSocketAppender.DEFAULT_PORT, defaults.getPort()); + assertEquals(ServerSocketReceiver.DEFAULT_BACKLOG, defaults.getBacklog()); + assertNull(defaults.getAddress()); + assertNull(defaults.getInetAddress()); + assertSame(ServerSocketFactory.getDefault(), defaults.getServerSocketFactory()); + } + + @Test + public void defaultListenerOwnsTheServerSocket() throws Exception { + ServerListener defaultListener = + new ServerSocketReceiver().createServerListener(serverSocket); + + assertTrue(defaultListener instanceof RemoteAppenderServerListener); + defaultListener.close(); + assertTrue(serverSocket.isClosed()); + } + + @Test + public void onStopBeforeAnyStartDoesNothing() throws Exception { + // there is no runner to stop yet + receiver.onStop(); + assertNull(context.getLastStatus()); + } + } From 962c719c5a818717cc65439487c33e5dca7d88b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:59:17 +0000 Subject: [PATCH 34/79] test: run SimpleSocketServer main tests on the plain JVM Replace the Robolectric-only SimpleSocketServerMainTest with plain JVM tests that mock the JoranConfigurator main creates, so the tests that prove the SimpleSSLSocketServer.main fix and cover doMain/configureLC run (and are checked) everywhere. Also pin that doMain hands the default context and port to the server class, and that extra arguments are rejected. Make the loopback accept test wait for the client's socket node to end before reading the server log, and use a non-default loopback address in the ServerSocketReceiver address test so it detects a wrong lookup. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../net/SimpleSSLSocketServerTest.java | 23 +- .../net/SimpleSocketServerMainRule.java | 160 ++++++++++++++ .../net/SimpleSocketServerMainTest.java | 202 ------------------ .../classic/net/SimpleSocketServerTest.java | 122 ++++++++++- .../net/server/ServerSocketReceiverTest.java | 6 +- 5 files changed, 303 insertions(+), 210 deletions(-) create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerMainRule.java delete mode 100644 logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerMainTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSSLSocketServerTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSSLSocketServerTest.java index 1ad865d92..4a0d93c7a 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSSLSocketServerTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSSLSocketServerTest.java @@ -15,6 +15,8 @@ */ package ch.qos.logback.classic.net; +import static ch.qos.logback.classic.net.SimpleSocketServerMainRule.CONFIG_FILE; +import static ch.qos.logback.classic.net.SimpleSocketServerMainRule.OUT_OF_RANGE_PORT; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; @@ -31,22 +33,25 @@ import javax.net.ssl.SSLServerSocketFactory; import org.junit.After; +import org.junit.Rule; import org.junit.Test; import org.junit.function.ThrowingRunnable; import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.status.Status; import ch.qos.logback.core.status.StatusChecker; /** * Unit tests for {@link SimpleSSLSocketServer}. - *

- * Its {@code main} is tested in {@link SimpleSocketServerMainTest}. */ public class SimpleSSLSocketServerTest { private final LoggerContext lc = new LoggerContext(); + @Rule + public final SimpleSocketServerMainRule main = new SimpleSocketServerMainRule(); + @After public void tearDown() { lc.stop(); @@ -99,4 +104,18 @@ public void defaultSslContextServesSslServerSockets() throws Exception { socket.close(); } } + + @Test + public void mainConfiguresTheDefaultContextFromTheFileAndStartsAnSslServerOnThePort() + throws Throwable { + main.run(() -> SimpleSSLSocketServer.main(new String[] {OUT_OF_RANGE_PORT, CONFIG_FILE})); + + main.assertConfiguredDefaultContextFrom(CONFIG_FILE); + ILoggingEvent failure = main.awaitServerFailure(); + assertEquals("Logback SimpleSSLSocketServer (port " + OUT_OF_RANGE_PORT + ")", + failure.getThreadName()); + assertEquals(IllegalArgumentException.class.getName(), + failure.getThrowableProxy().getClassName()); + assertTrue(main.logged("Listening on port " + OUT_OF_RANGE_PORT)); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerMainRule.java b/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerMainRule.java new file mode 100644 index 000000000..7f9461ae0 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerMainRule.java @@ -0,0 +1,160 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.net; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mockConstruction; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import org.junit.function.ThrowingRunnable; +import org.junit.rules.ExternalResource; +import org.mockito.InOrder; +import org.mockito.MockedConstruction; +import org.slf4j.LoggerFactory; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.joran.JoranConfigurator; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.util.ContextInitializer; +import ch.qos.logback.core.AppenderBase; +import ch.qos.logback.core.joran.spi.JoranException; + +/** + * Runs the command-line entry points of {@link SimpleSocketServer} and + * {@link SimpleSSLSocketServer} on the plain JVM, and restores the default + * logger context that they configure. + *

+ * Parsing a configuration file needs Android's XML pull parser, so the + * {@link JoranConfigurator} that {@code main} creates is a mock. Configuring + * it from {@link #CONFIG_FILE} stands in for that file: it attaches, to the + * root logger of the context, an appender that records the events logged and + * the threads that logged them. + */ +final class SimpleSocketServerMainRule extends ExternalResource { + + /** A port out of the TCP range: a server started on it fails to listen and ends. */ + static final String OUT_OF_RANGE_PORT = "70000"; + static final String CONFIG_FILE = "simple-socket-server.xml"; + + private static final long TIMEOUT_MILLIS = 10000; + + private final LoggerContext defaultContext = (LoggerContext) LoggerFactory.getILoggerFactory(); + private final BlockingQueue records = new LinkedBlockingQueue(); + private final List seen = new ArrayList(); + private List configurators = Collections.emptyList(); + private boolean ran; + + LoggerContext defaultContext() { + return defaultContext; + } + + /** Runs the given entry point, with every {@link JoranConfigurator} it creates mocked. */ + void run(ThrowingRunnable main) throws Throwable { + ran = true; + try (MockedConstruction mocked = mockConstruction(JoranConfigurator.class, + (configurator, context) -> doAnswer(invocation -> { + Recorder recorder = new Recorder(); + recorder.setContext(defaultContext); + recorder.start(); + defaultContext.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(recorder); + return null; + }).when(configurator).doConfigure(CONFIG_FILE))) { + main.run(); + configurators = new ArrayList(mocked.constructed()); + } + } + + /** Asserts that the entry point configured the default context from the given file. */ + void assertConfiguredDefaultContextFrom(String configFile) throws JoranException { + assertEquals(1, configurators.size()); + JoranConfigurator configurator = configurators.get(0); + InOrder inOrder = inOrder(configurator); + inOrder.verify(configurator).setContext(defaultContext); + inOrder.verify(configurator).doConfigure(configFile); + } + + /** + * Waits for the server to log that it failed, then for the thread that + * logged it to end; returns the failure. + */ + ILoggingEvent awaitServerFailure() throws InterruptedException { + Logged next; + while ((next = records.poll(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) != null) { + seen.add(next.event); + if ("Unexpected failure in run method".equals(next.event.getFormattedMessage())) { + next.thread.join(TIMEOUT_MILLIS); + assertFalse("the server thread is still running", next.thread.isAlive()); + return next.event; + } + } + throw new AssertionError("the server did not report a failure; it logged " + seen); + } + + /** Whether the given message was logged before the failure. */ + boolean logged(String message) { + for (ILoggingEvent event : seen) { + if (message.equals(event.getFormattedMessage())) { + return true; + } + } + return false; + } + + @Override + protected void after() { + if (!ran) { + return; + } + // undo what the entry point configured into the default context + defaultContext.reset(); + try { + new ContextInitializer(defaultContext).autoConfig(); + } catch (JoranException e) { + throw new IllegalStateException(e); + } + } + + /** An event, and the thread that logged it. */ + private static final class Logged { + final ILoggingEvent event; + final Thread thread; + + Logged(ILoggingEvent event, Thread thread) { + this.event = event; + this.thread = thread; + } + } + + /** Records the events it receives, and the threads that logged them. */ + private final class Recorder extends AppenderBase { + @Override + protected void append(ILoggingEvent event) { + // resolve the thread name now, on the logging thread + event.prepareForDeferredProcessing(); + records.offer(new Logged(event, Thread.currentThread())); + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerMainTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerMainTest.java deleted file mode 100644 index 0b35afe43..000000000 --- a/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerMainTest.java +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Copyright 2019 Anthony Trinh - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package ch.qos.logback.classic.net; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.OutputStreamWriter; -import java.io.Writer; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.TimeUnit; - -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.slf4j.LoggerFactory; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.LoggerContext; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.classic.util.ContextInitializer; -import ch.qos.logback.core.AppenderBase; - -/** - * Tests the command-line entry points of {@link SimpleSocketServer} and - * {@link SimpleSSLSocketServer}. They configure the default context from an - * XML file, which needs Android's XML pull parser, hence Robolectric. - *

- * The port given to {@code main} is out of the TCP range, so the server thread - * that {@code main} starts logs its failure to listen and ends on its own. - */ -@RunWith(RobolectricTestRunner.class) -public class SimpleSocketServerMainTest { - - private static final String OUT_OF_RANGE_PORT = "70000"; - private static final long TIMEOUT_MILLIS = 10000; - - @Rule - public TemporaryFolder tmp = new TemporaryFolder(); - - private LoggerContext defaultContext; - - @Before - public void setUp() { - defaultContext = (LoggerContext) LoggerFactory.getILoggerFactory(); - } - - @After - public void tearDown() throws Exception { - // undo what main configured into the default context - defaultContext.reset(); - new ContextInitializer(defaultContext).autoConfig(); - } - - @Test - public void mainConfiguresTheDefaultContextAndStartsASocketServerOnThePort() throws Exception { - SimpleSocketServer.main(new String[] {OUT_OF_RANGE_PORT, writeCapturingConfig()}); - - CapturingAppender capture = CapturingAppender.from(defaultContext); - assertNotNull("main did not apply the config file", capture); - ILoggingEvent failure = capture.awaitMessage("Unexpected failure in run method"); - capture.joinLoggingThreads(); - - assertNotNull(failure); - assertEquals("Logback SimpleSocketServer (port " + OUT_OF_RANGE_PORT + ")", failure.getThreadName()); - assertEquals(IllegalArgumentException.class.getName(), failure.getThrowableProxy().getClassName()); - assertTrue(capture.hasMessage("Listening on port " + OUT_OF_RANGE_PORT)); - } - - @Test - public void sslMainStartsAnSslSocketServer() throws Exception { - SimpleSSLSocketServer.main(new String[] {OUT_OF_RANGE_PORT, writeCapturingConfig()}); - - CapturingAppender capture = CapturingAppender.from(defaultContext); - assertNotNull("main did not apply the config file", capture); - ILoggingEvent failure = capture.awaitMessage("Unexpected failure in run method"); - capture.joinLoggingThreads(); - - assertNotNull(failure); - assertEquals("Logback SimpleSSLSocketServer (port " + OUT_OF_RANGE_PORT + ")", failure.getThreadName()); - } - - @Test - public void configureLCResetsTheContextAndAppliesTheConfigFile() throws Exception { - LoggerContext lc = new LoggerContext(); - Logger stale = lc.getLogger("stale"); - stale.setLevel(Level.ERROR); - try { - SimpleSocketServer.configureLC(lc, writeCapturingConfig()); - - assertNull(stale.getLevel()); - assertNotNull(CapturingAppender.from(lc)); - assertEquals(Level.DEBUG, lc.getLogger(Logger.ROOT_LOGGER_NAME).getLevel()); - } finally { - lc.stop(); - } - } - - /** - * Writes a configuration that sends every event of the context to a - * {@link CapturingAppender} named {@code CAPTURE}. - */ - private String writeCapturingConfig() throws IOException { - File file = tmp.newFile("capture.xml"); - Writer writer = new OutputStreamWriter(new FileOutputStream(file), "UTF-8"); - try { - writer.write("\n" - + " \n" - + " \n" - + "\n"); - } finally { - writer.close(); - } - return file.getAbsolutePath(); - } - - /** - * An appender, configured by class name, that keeps the events it receives - * and the threads that logged them. - */ - public static class CapturingAppender extends AppenderBase { - private final BlockingQueue events = new LinkedBlockingQueue(); - private final List seen = new ArrayList(); - private final List threads = new ArrayList(); - - static CapturingAppender from(LoggerContext context) { - return (CapturingAppender) context.getLogger(Logger.ROOT_LOGGER_NAME).getAppender("CAPTURE"); - } - - @Override - protected void append(ILoggingEvent event) { - // resolve the thread name now, on the logging thread - event.prepareForDeferredProcessing(); - synchronized (threads) { - if (!threads.contains(Thread.currentThread())) { - threads.add(Thread.currentThread()); - } - } - events.offer(event); - } - - ILoggingEvent awaitMessage(String message) throws InterruptedException { - ILoggingEvent event; - while ((event = events.poll(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) != null) { - seen.add(event); - if (message.equals(event.getFormattedMessage())) { - return event; - } - } - return null; - } - - boolean hasMessage(String message) { - for (ILoggingEvent event : seen) { - if (message.equals(event.getFormattedMessage())) { - return true; - } - } - return false; - } - - void joinLoggingThreads() throws InterruptedException { - List copy; - synchronized (threads) { - copy = new ArrayList(threads); - } - for (Thread thread : copy) { - if (thread != Thread.currentThread()) { - thread.join(TIMEOUT_MILLIS); - assertFalse(thread.isAlive()); - } - } - } - } -} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerTest.java index 035f7f3b7..600db8448 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/SimpleSocketServerTest.java @@ -15,13 +15,18 @@ */ package ch.qos.logback.classic.net; +import static ch.qos.logback.classic.net.SimpleSocketServerMainRule.CONFIG_FILE; +import static ch.qos.logback.classic.net.SimpleSocketServerMainRule.OUT_OF_RANGE_PORT; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verify; @@ -33,6 +38,7 @@ import java.net.ServerSocket; import java.net.Socket; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -40,13 +46,17 @@ import org.junit.After; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.junit.function.ThrowingRunnable; +import org.mockito.InOrder; +import org.mockito.MockedConstruction; import org.mockito.MockedStatic; import ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.joran.JoranConfigurator; import ch.qos.logback.classic.net.mock.MockAppender; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.classic.spi.LoggingEvent; @@ -56,9 +66,6 @@ /** * Unit tests for {@link SimpleSocketServer}. - *

- * {@code main} with valid arguments configures a context from XML, which needs - * the Android XML parser; it is tested in {@link SimpleSocketServerMainTest}. */ public class SimpleSocketServerTest { @@ -71,6 +78,9 @@ public class SimpleSocketServerTest { private final String testThreadName = Thread.currentThread().getName(); private SimpleSocketServer server; + @Rule + public final SimpleSocketServerMainRule main = new SimpleSocketServerMainRule(); + @Before public void setUp() { serverLog.setContext(lc); @@ -89,6 +99,7 @@ public void tearDown() { CloseUtil.closeQuietly(socket); } Thread.currentThread().setName(testThreadName); + RecordingServer.started.clear(); lc.stop(); } @@ -122,6 +133,9 @@ public void serverDispatchesClientEventsAndStopsQuietlyWhenClosed() throws Excep server.close(); server.join(TIMEOUT_MILLIS); + // the client's node ends once the server closed it, and says so to the + // server; after that, nothing else logs to the server's logger + assertTrue(awaitMessageStartingWith(serverEvents, "Removing ")); } finally { client.close(); } @@ -287,6 +301,75 @@ public void run() throws Throwable { + System.lineSeparator(), stderr); } + @Test + public void mainPrintsUsageAndExitsOnTooManyArguments() throws Throwable { + Runtime runtime = mock(Runtime.class); + String stderr; + try (MockedStatic runtimeStatic = mockStatic(Runtime.class)) { + runtimeStatic.when(Runtime::getRuntime).thenReturn(runtime); + StderrCapture capture = new StderrCapture(); + try { + main.run(() -> SimpleSocketServer.main(new String[] {"4560", CONFIG_FILE, "extra"})); + } finally { + stderr = capture.stop(); + } + } + + verify(runtime).exit(1); + assertTrue(stderr, stderr.startsWith("Wrong number of arguments." + System.lineSeparator())); + // only reached because the mocked exit returns: the port was never parsed + assertEquals("Logback SimpleSocketServer (port -1)", main.awaitServerFailure().getThreadName()); + } + + @Test + public void mainConfiguresTheDefaultContextFromTheFileAndStartsASocketServerOnThePort() + throws Throwable { + main.run(() -> SimpleSocketServer.main(new String[] {OUT_OF_RANGE_PORT, CONFIG_FILE})); + + main.assertConfiguredDefaultContextFrom(CONFIG_FILE); + ILoggingEvent failure = main.awaitServerFailure(); + assertEquals("Logback SimpleSocketServer (port " + OUT_OF_RANGE_PORT + ")", + failure.getThreadName()); + assertEquals(IllegalArgumentException.class.getName(), + failure.getThrowableProxy().getClassName()); + assertTrue(main.logged("Listening on port " + OUT_OF_RANGE_PORT)); + } + + @Test + public void doMainStartsAServerOfTheGivenClassForTheDefaultContextOnThePort() throws Throwable { + main.run(() -> SimpleSocketServer.doMain(RecordingServer.class, + new String[] {"4560", CONFIG_FILE})); + + main.assertConfiguredDefaultContextFrom(CONFIG_FILE); + assertEquals(1, RecordingServer.started.size()); + RecordingServer started = RecordingServer.started.get(0); + assertSame(main.defaultContext(), started.context); + assertEquals(4560, started.port); + } + + @Test + public void configureLCResetsTheContextThenConfiguresItFromTheFile() throws Exception { + final Logger stale = lc.getLogger("stale"); + stale.setLevel(Level.ERROR); + final List staleLevelWhenConfigured = new ArrayList(); + try (MockedConstruction configurators = mockConstruction( + JoranConfigurator.class, (configurator, context) -> doAnswer(invocation -> { + staleLevelWhenConfigured.add(stale.getLevel()); + return null; + }).when(configurator).doConfigure("server.xml"))) { + + SimpleSocketServer.configureLC(lc, "server.xml"); + + assertEquals(1, configurators.constructed().size()); + JoranConfigurator configurator = configurators.constructed().get(0); + InOrder inOrder = inOrder(configurator); + inOrder.verify(configurator).setContext(lc); + inOrder.verify(configurator).doConfigure("server.xml"); + } + // the context was reset before it was configured + assertEquals(Collections.singletonList(null), staleLevelWhenConfigured); + } + private SimpleSocketServer newServer(final ServerSocketFactory factory) { SimpleSocketServer server = new SimpleSocketServer(lc, 0) { @Override @@ -309,6 +392,17 @@ private static boolean awaitMessage(MockAppender appender, String message) return false; } + private static boolean awaitMessageStartingWith(MockAppender appender, String prefix) + throws InterruptedException { + ILoggingEvent event; + while ((event = appender.awaitAppend(TIMEOUT_MILLIS)) != null) { + if (event.getFormattedMessage().startsWith(prefix)) { + return true; + } + } + return false; + } + private boolean containsMessage(Level level, String message) { for (ILoggingEvent event : eventsAt(level)) { if (message.equals(event.getFormattedMessage())) { @@ -365,6 +459,28 @@ public ServerSocket createServerSocket(int port, int backlog, InetAddress addres } } + /** + * A server that {@code doMain} can create; it records that it was started + * instead of listening. + */ + public static class RecordingServer extends SimpleSocketServer { + static final List started = new ArrayList(); + + final LoggerContext context; + final int port; + + public RecordingServer(LoggerContext context, int port) { + super(context, port); + this.context = context; + this.port = port; + } + + @Override + public synchronized void start() { + started.add(this); + } + } + /** Captures what is printed to {@link System#err} until {@link #stop()}. */ private static final class StderrCapture { private final PrintStream original = System.err; diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/server/ServerSocketReceiverTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/server/ServerSocketReceiverTest.java index ad413ad98..5f1aac21a 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/net/server/ServerSocketReceiverTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/server/ServerSocketReceiverTest.java @@ -173,15 +173,15 @@ protected ServerRunner createServerRunner(ServerListener l configured.setContext(context); configured.setPort(1234); configured.setBacklog(7); - configured.setAddress("127.0.0.1"); + configured.setAddress("127.0.0.2"); configured.start(); assertTrue(configured.isStarted()); assertEquals(1234, configured.getPort()); assertEquals(7, configured.getBacklog()); - assertEquals("127.0.0.1", configured.getAddress()); - assertEquals(Arrays.asList(1234, 7, InetAddress.getByName("127.0.0.1")), socketArgs); + assertEquals("127.0.0.2", configured.getAddress()); + assertEquals(Arrays.asList(1234, 7, InetAddress.getByName("127.0.0.2")), socketArgs); configured.stop(); } From a671e04ed714bd608646de52313697b81920a313 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:23:03 +0000 Subject: [PATCH 35/79] refactor: merge HardenedObjectInputStream's unreachable reflection catches initObjectFilter() looks up java.io.ObjectInputFilter by reflection and had one catch block per checked exception. Two of them can never run: NoSuchMethodException (where ObjectInputFilter exists, so do ObjectInputStream.setObjectInputFilter and Config.createFilter) and IllegalAccessException (both methods are public members of public classes). Their handlers are identical to their siblings', so they are merged into multi-catch clauses. Behavior is unchanged for every input. The remaining paths are now tested: - ClassNotFoundException (Java 8, Android): the class is loaded by a class loader that hides java.io.ObjectInputFilter; the stream then relies on the allowlist alone (an array over the limit is accepted, non-allowlisted classes are still rejected). - InvocationTargetException: a subclass that sets its own filter while the stream header is read makes setObjectInputFilter fail ("filter can not be set more than once"). The old comment called this unreachable. The tests also cover the null allowlist, the List constructor, addToWhitelist, the rejection message and the array limit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../core/net/HardenedObjectInputStream.java | 12 +- .../net/HardenedObjectInputStreamTest.java | 212 ++++++++++++++++++ 2 files changed, 216 insertions(+), 8 deletions(-) diff --git a/logback-android/src/main/java/ch/qos/logback/core/net/HardenedObjectInputStream.java b/logback-android/src/main/java/ch/qos/logback/core/net/HardenedObjectInputStream.java index 9da81c308..b8780cddc 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/net/HardenedObjectInputStream.java +++ b/logback-android/src/main/java/ch/qos/logback/core/net/HardenedObjectInputStream.java @@ -83,20 +83,16 @@ private void initObjectFilter() { oifConfigClass = Class.forName("java.io.ObjectInputFilter$Config"); setObjectInputFilterMethod = ObjectInputStream.class.getMethod("setObjectInputFilter", oifClass); createFilterMethod = oifConfigClass.getMethod("createFilter", String.class); - } catch (ClassNotFoundException e) { - return; - } catch (NoSuchMethodException e) { + } catch (ClassNotFoundException | NoSuchMethodException e) { + // no ObjectInputFilter (Java 8, Android) return; } try { Object filter = createFilterMethod.invoke(null, "maxarray=" + ARRAY_LIMIT + ";maxdepth=" + DEPTH_LIMIT + ";"); setObjectInputFilterMethod.invoke(this, filter); - } catch (IllegalAccessException e) { - // this code should be unreachable - throw new RuntimeException("Failed to initialize object filter", e); - } catch (InvocationTargetException e) { - // this code should be unreachable + } catch (IllegalAccessException | InvocationTargetException e) { + // e.g. a subclass already set a filter on this stream throw new RuntimeException("Failed to initialize object filter", e); } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/HardenedObjectInputStreamTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/HardenedObjectInputStreamTest.java index e27417ef3..eceab8f88 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/HardenedObjectInputStreamTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/HardenedObjectInputStreamTest.java @@ -20,9 +20,15 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; import java.io.InvalidClassException; +import java.io.ObjectInputStream; import java.io.ObjectOutputStream; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashSet; +import java.util.List; import java.util.Set; import org.junit.After; @@ -122,4 +128,210 @@ private Set buildEvilHashset() { return root; } + @Test + public void acceptsJavaLangAndJavaUtilClassesWithoutWhitelist() throws Exception { + List list = new ArrayList(); + list.add("text"); + list.add(Integer.valueOf(42)); + + inputStream = new HardenedObjectInputStream(new ByteArrayInputStream(serialize(list)), (String[]) null); + try { + assertEquals(list, inputStream.readObject()); + } finally { + inputStream.close(); + } + } + + @Test + public void rejectsClassesThatAreNotWhitelisted() throws Exception { + inputStream = new HardenedObjectInputStream(new ByteArrayInputStream(serialize(new Innocent())), (String[]) null); + try { + InvalidClassException e = assertThrows(InvalidClassException.class, () -> inputStream.readObject()); + assertEquals("Unauthorized deserialization attempt; " + Innocent.class.getName(), e.getMessage()); + } finally { + inputStream.close(); + } + } + + @Test + public void acceptsClassesOnWhitelistGivenAsList() throws Exception { + Innocent innocent = new Innocent(); + innocent.setaString("listed"); + List listedClasses = Arrays.asList("some.other.Clazz", Innocent.class.getName()); + + inputStream = new HardenedObjectInputStream(new ByteArrayInputStream(serialize(innocent)), listedClasses); + try { + assertEquals(innocent, inputStream.readObject()); + } finally { + inputStream.close(); + } + } + + @Test + public void acceptsClassesAddedToTheWhitelist() throws Exception { + Innocent innocent = new Innocent(); + innocent.setaString("added"); + + inputStream = new HardenedObjectInputStream(new ByteArrayInputStream(serialize(innocent)), new String[0]); + inputStream.addToWhitelist(Arrays.asList(Innocent.class.getName())); + try { + assertEquals(innocent, inputStream.readObject()); + } finally { + inputStream.close(); + } + } + + @Test + public void acceptsArraysUpToTheLimit() throws Exception { + inputStream = new HardenedObjectInputStream(new ByteArrayInputStream(serialize(new int[10000])), new String[] {"[I"}); + try { + assertEquals(10000, ((int[]) inputStream.readObject()).length); + } finally { + inputStream.close(); + } + } + + @Test + public void rejectsArraysLongerThanTheLimit() throws Exception { + // the array limit relies on java.io.ObjectInputFilter (Java 9+) + Assume.assumeTrue(hasObjectInputFilter()); + + inputStream = new HardenedObjectInputStream(new ByteArrayInputStream(serialize(new int[10001])), new String[] {"[I"}); + try { + InvalidClassException e = assertThrows(InvalidClassException.class, () -> inputStream.readObject()); + assertTrue(e.getMessage(), e.getMessage().contains("REJECTED")); + } finally { + inputStream.close(); + } + } + + @Test + public void reliesOnWhitelistAloneWhereObjectInputFilterIsMissing() throws Exception { + // as on Java 8 and Android, which have no java.io.ObjectInputFilter + Class isolatedClass = new ObjectInputFilterHidingClassLoader() + .loadClass(HardenedObjectInputStream.class.getName()); + assertNotSame(HardenedObjectInputStream.class, isolatedClass); + + // no array limit... + ObjectInputStream in = newIsolatedInputStream(isolatedClass, serialize(new int[10001]), "[I"); + try { + assertEquals(10001, ((int[]) in.readObject()).length); + } finally { + in.close(); + } + + // ...but the whitelist still applies + final ObjectInputStream rejecting = newIsolatedInputStream(isolatedClass, serialize(new Innocent()), "[I"); + try { + assertThrows(InvalidClassException.class, () -> rejecting.readObject()); + } finally { + rejecting.close(); + } + } + + @Test + public void failsWhenTheObjectFilterCannotBeInstalled() throws Exception { + Assume.assumeTrue(hasObjectInputFilter()); + final byte[] bytes = serialize("anything"); + + RuntimeException e = assertThrows(RuntimeException.class, + () -> new FilterPresettingInputStream(new ByteArrayInputStream(bytes))); + + assertEquals("Failed to initialize object filter", e.getMessage()); + assertTrue(e.getCause() instanceof InvocationTargetException); + // ObjectInputStream allows setting a stream's filter only once + assertTrue(e.getCause().getCause() instanceof IllegalStateException); + } + + private static byte[] serialize(Object o) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ObjectOutputStream objectOut = new ObjectOutputStream(out); + objectOut.writeObject(o); + objectOut.close(); + return out.toByteArray(); + } + + private static ObjectInputStream newIsolatedInputStream(Class isolatedClass, byte[] bytes, String... allowedClasses) + throws Exception { + return (ObjectInputStream) isolatedClass.getConstructor(InputStream.class, String[].class) + .newInstance(new ByteArrayInputStream(bytes), allowedClasses); + } + + /** + * Defines its own copy of {@link HardenedObjectInputStream}, for which + * {@code java.io.ObjectInputFilter} cannot be found. + */ + private static class ObjectInputFilterHidingClassLoader extends ClassLoader { + + ObjectInputFilterHidingClassLoader() { + super(HardenedObjectInputStreamTest.class.getClassLoader()); + } + + @Override + protected synchronized Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.equals("java.io.ObjectInputFilter")) { + throw new ClassNotFoundException(name); + } + if (!name.equals(HardenedObjectInputStream.class.getName())) { + return super.loadClass(name, resolve); + } + Class c = findLoadedClass(name); + if (c == null) { + byte[] bytes = readClassFile(HardenedObjectInputStream.class); + // same code source as the original class, so that coverage tools record it + c = defineClass(name, bytes, 0, bytes.length, HardenedObjectInputStream.class.getProtectionDomain()); + } + return c; + } + + private static byte[] readClassFile(Class c) throws ClassNotFoundException { + InputStream in = c.getResourceAsStream(c.getSimpleName() + ".class"); + if (in == null) { + throw new ClassNotFoundException(c.getName()); + } + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int n; + while ((n = in.read(buffer)) != -1) { + out.write(buffer, 0, n); + } + return out.toByteArray(); + } catch (IOException e) { + throw new ClassNotFoundException(c.getName(), e); + } finally { + try { + in.close(); + } catch (IOException e) { + // ignore + } + } + } + } + + /** + * Sets an object filter of its own while the stream header is read, i.e. + * before {@link HardenedObjectInputStream} installs its filter. + */ + private static class FilterPresettingInputStream extends HardenedObjectInputStream { + + FilterPresettingInputStream(InputStream in) throws IOException { + super(in, new String[0]); + } + + @Override + protected void readStreamHeader() throws IOException { + super.readStreamHeader(); + try { + // java.io.ObjectInputFilter (Java 9+) is not part of the Android API + Class filterClass = Class.forName("java.io.ObjectInputFilter"); + Object filter = Class.forName("java.io.ObjectInputFilter$Config") + .getMethod("createFilter", String.class).invoke(null, "maxdepth=1"); + ObjectInputStream.class.getMethod("setObjectInputFilter", filterClass).invoke(this, filter); + } catch (ReflectiveOperationException e) { + throw new IOException(e); + } + } + } + } From 3908174f5e3f5a04a3a1602d9a312e6a6497470f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:23:12 +0000 Subject: [PATCH 36/79] refactor: make AbstractSocketAppender's dispatch loop fully testable Two lines of connectSocketAndDispatchEvents() could never be covered: - dispatchEvents(objectWriter) looped forever and only ever ended by throwing, so the call never returned and its line never completed. The loop moves to the call site (while (true) dispatchEvent(...)), and dispatchEvent() handles one event, in the same order as before. - After an SSLHandshakeException it slept a hard-coded 30 seconds (DEFAULT_RECONNECTION_DELAY). The delay is now a field with that same default. As with setAcceptConnectionTimeout(), a package-private setter exists only for unit tests. Behavior is unchanged for every input. Tests pin the handshake-failure path (the wait is not a plain "connection failed", and the appender reconnects after it or shuts down when interrupted). They also pin host-resolution give-up and retry (using a malformed IPv6 literal, which fails without a DNS lookup), the reconnectionDelay-unset fallback, repeated start, stop before dispatch, null events, the connectionFailed messages and the property accessors. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../core/net/AbstractSocketAppender.java | 42 ++- .../core/net/AbstractSocketAppenderTest.java | 284 ++++++++++++++++++ 2 files changed, 313 insertions(+), 13 deletions(-) diff --git a/logback-android/src/main/java/ch/qos/logback/core/net/AbstractSocketAppender.java b/logback-android/src/main/java/ch/qos/logback/core/net/AbstractSocketAppender.java index 86e4278f5..6f6ed2425 100755 --- a/logback-android/src/main/java/ch/qos/logback/core/net/AbstractSocketAppender.java +++ b/logback-android/src/main/java/ch/qos/logback/core/net/AbstractSocketAppender.java @@ -83,6 +83,7 @@ public abstract class AbstractSocketAppender extends AppenderBase private Duration reconnectionDelay = new Duration(DEFAULT_RECONNECTION_DELAY); private int queueSize = DEFAULT_QUEUE_SIZE; private int acceptConnectionTimeout = DEFAULT_ACCEPT_CONNECTION_DELAY; + private int handshakeFailureDelay = DEFAULT_RECONNECTION_DELAY; private Duration eventDelayLimit = new Duration(DEFAULT_EVENT_DELAY_TIMEOUT); private boolean lazyInit = false; @@ -237,10 +238,14 @@ private void connectSocketAndDispatchEvents() { try { ObjectWriter objectWriter = createObjectWriterForSocket(); addInfo(peerId + "connection established"); - dispatchEvents(objectWriter); + // ends only by an IOException (connection lost) or an + // InterruptedException (appender stopped) + while (true) { + dispatchEvent(objectWriter); + } } catch (javax.net.ssl.SSLHandshakeException she) { // FIXME - Thread.sleep(DEFAULT_RECONNECTION_DELAY); + Thread.sleep(handshakeFailureDelay); } catch (IOException ex) { addInfo(peerId + "connection failed: " + ex); } finally { @@ -273,17 +278,15 @@ private SocketConnector createConnector(InetAddress address, int port, int initi return connector; } - private void dispatchEvents(ObjectWriter objectWriter) throws InterruptedException, IOException { - while (true) { - E event = deque.takeFirst(); - postProcessEvent(event); - Serializable serializableEvent = getPST().transform(event); - try { - objectWriter.write(serializableEvent); - } catch (IOException e) { - tryReAddingEventToFrontOfQueue(event); - throw e; - } + private void dispatchEvent(ObjectWriter objectWriter) throws InterruptedException, IOException { + E event = deque.takeFirst(); + postProcessEvent(event); + Serializable serializableEvent = getPST().transform(event); + try { + objectWriter.write(serializableEvent); + } catch (IOException e) { + tryReAddingEventToFrontOfQueue(event); + throw e; } } @@ -480,4 +483,17 @@ void setAcceptConnectionTimeout(int acceptConnectionTimeout) { this.acceptConnectionTimeout = acceptConnectionTimeout; } + /** + * Sets how long to wait before reconnecting after the SSL handshake with + * the remote peer failed (default {@link #DEFAULT_RECONNECTION_DELAY}). + *

+ * This property is configurable only to support instrumentation for unit + * testing. + * + * @param handshakeFailureDelay delay in milliseconds + */ + void setHandshakeFailureDelay(int handshakeFailureDelay) { + this.handshakeFailureDelay = handshakeFailureDelay; + } + } diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/AbstractSocketAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/AbstractSocketAppenderTest.java index abd7b83ee..8ed40805d 100755 --- a/logback-android/src/test/java/ch/qos/logback/core/net/AbstractSocketAppenderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/AbstractSocketAppenderTest.java @@ -17,6 +17,7 @@ import java.io.IOException; import java.io.OutputStream; +import java.net.ConnectException; import java.net.InetAddress; import java.net.Socket; import java.util.concurrent.Executors; @@ -25,14 +26,20 @@ import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import javax.net.ssl.SSLHandshakeException; + import ch.qos.logback.core.net.mock.MockContext; +import ch.qos.logback.core.net.server.MockScheduledExecutorService; import ch.qos.logback.core.spi.PreSerializationTransformer; import ch.qos.logback.core.testUtil.NetworkTestUtil; import ch.qos.logback.core.util.Duration; import ch.qos.logback.core.util.ExecutorServiceUtil; import org.junit.After; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import org.junit.Before; import org.junit.Test; @@ -43,6 +50,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; @@ -51,6 +59,7 @@ import static org.mockito.Mockito.reset; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -72,6 +81,12 @@ public class AbstractSocketAppenderTest { */ private static final int TIMEOUT = 5000; + /** + * A malformed IPv6 literal: {@link InetAddress#getByName(String)} rejects it + * with an {@link java.net.UnknownHostException} without any network lookup. + */ + private static final String UNRESOLVABLE_HOST = "[not-an-ipv6-literal"; + private ScheduledExecutorService executorService; private MockContext mockContext; private PreSerializationTransformer preSerializationTransformer; @@ -82,6 +97,7 @@ public class AbstractSocketAppenderTest { private LinkedBlockingDeque deque; private QueueFactory queueFactory; private InstrumentedSocketAppender appender; + private InstrumentedSocketAppender inlineAppender; @Before public void setupValidAppenderWithMockDependencies() throws Exception { @@ -113,6 +129,9 @@ public void setupValidAppenderWithMockDependencies() throws Exception { public void tearDown() throws Exception { appender.stop(); assertFalse(appender.isStarted()); + if (inlineAppender != null) { + inlineAppender.stop(); + } executorService.shutdownNow(); assertTrue(executorService.awaitTermination(TIMEOUT, TimeUnit.MILLISECONDS)); @@ -531,6 +550,271 @@ public void usesConfiguredAcceptConnectionTimeoutAndResetsSocketTimeoutAfterSucc inOrder.verify(socket).setSoTimeout(0); } + @Test + public void ignoresStartWhenAlreadyStarted() throws Exception { + + // given + appender.setLazy(true); + appender.start(); + + // when + appender.start(); + + // then + assertTrue(appender.isStarted()); + verify(queueFactory, times(1)).newLinkedBlockingDeque(anyInt()); + } + + @Test + public void stopsLazyAppenderThatNeverSubmittedItsDispatchTask() throws Exception { + + // given + appender.setLazy(true); + appender.start(); + + // when + appender.stop(); + + // then + assertFalse(appender.isStarted()); + verify(appender, never()).newConnector(any(InetAddress.class), anyInt(), anyLong(), anyLong()); + } + + @Test + public void ignoresNullEvents() throws Exception { + + // given + appender.setLazy(true); + appender.start(); + + // when + appender.append(null); + + // then: nothing is queued, and a lazy appender still does not connect + verifyNoInteractions(deque); + verify(appender, never()).newConnector(any(InetAddress.class), anyInt(), anyLong(), anyLong()); + } + + @Test + public void givesUpResolvingRemoteHostWhenReconnectionDelayIsZero() throws Exception { + + // given + InstrumentedSocketAppender syncAppender = newAppenderDispatchingOnCallingThread(); + syncAppender.setRemoteHost(UNRESOLVABLE_HOST); + syncAppender.setReconnectionDelay(new Duration(0)); + + // when + syncAppender.start(); + + // then + assertTrue(syncAppender.isStarted()); + String peerId = "remote peer " + UNRESOLVABLE_HOST + ":" + AbstractSocketAppender.DEFAULT_PORT + ": "; + verify(syncAppender).addWarn(peerId + "unknown host: " + UNRESOLVABLE_HOST + " (will retry)"); + verify(syncAppender).addError(peerId + "gave up resolving host (reconnectionDelay is zero)"); + verify(syncAppender, never()).newConnector(any(InetAddress.class), anyInt(), anyLong(), anyLong()); + } + + @Test + public void givesUpResolvingRemoteHostWhenReconnectionDelayIsUnset() throws Exception { + + // given + InstrumentedSocketAppender syncAppender = newAppenderDispatchingOnCallingThread(); + syncAppender.setRemoteHost(UNRESOLVABLE_HOST); + syncAppender.setReconnectionDelay(null); + + // when + syncAppender.start(); + + // then + verify(syncAppender).addError(contains("gave up resolving host (reconnectionDelay is zero)")); + verify(syncAppender, never()).newConnector(any(InetAddress.class), anyInt(), anyLong(), anyLong()); + } + + @Test + public void retriesHostResolutionAfterReconnectionDelay() throws Exception { + + // given: the host becomes resolvable after the first failed attempt + // (e.g. a device that was offline at startup comes online) + final InstrumentedSocketAppender syncAppender = newAppenderDispatchingOnCallingThread(); + syncAppender.setRemoteHost(UNRESOLVABLE_HOST); + syncAppender.setReconnectionDelay(new Duration(1)); + doAnswer(invocation -> { + syncAppender.setRemoteHost("127.0.0.1"); + return invocation.callRealMethod(); + }).when(syncAppender).addWarn(contains("unknown host")); + + // when + syncAppender.start(); + + // then + verify(syncAppender, times(1)).addWarn(contains("unknown host")); + verify(syncAppender, never()).addError(anyString()); + verify(syncAppender).newConnector(InetAddress.getByAddress(new byte[] {127, 0, 0, 1}), + AbstractSocketAppender.DEFAULT_PORT, 0, 1); + } + + @Test + public void createsConnectorWithoutRetryDelayWhenReconnectionDelayIsUnset() throws Exception { + + // given + InstrumentedSocketAppender syncAppender = newAppenderDispatchingOnCallingThread(); + syncAppender.setRemoteHost("127.0.0.1"); + syncAppender.setPort(21); + syncAppender.setReconnectionDelay(null); + + // when + syncAppender.start(); + + // then + verify(syncAppender).newConnector(InetAddress.getByAddress(new byte[] {127, 0, 0, 1}), 21, 0, 0); + } + + @Test + public void waitsBeforeReconnectingAfterSslHandshakeFailure() throws Exception { + + // given: the handshake of the only connection fails; the pending interrupt + // then ends the wait before reconnecting (instead of sleeping 30 seconds) + InstrumentedSocketAppender syncAppender = newAppenderDispatchingOnCallingThread(); + syncAppender.setRemoteHost("127.0.0.1"); + mockOneSuccessfulSocketConnection(); + doAnswer(invocation -> { + Thread.currentThread().interrupt(); + throw new SSLHandshakeException("handshake failed"); + }).when(objectWriterFactory).newAutoFlushingObjectWriter(any(OutputStream.class)); + + // when + boolean interruptPending; + try { + syncAppender.start(); + } finally { + interruptPending = Thread.interrupted(); + } + + // then: the wait consumed the interrupt and shut the dispatcher down, + // without handling the handshake failure as a plain connection failure + assertFalse(interruptPending); + verify(socketConnector, times(1)).call(); + verify(socket).close(); + verify(syncAppender, never()).addInfo(contains("connection failed")); + InOrder inOrder = inOrder(syncAppender); + inOrder.verify(syncAppender).addInfo("remote peer 127.0.0.1:" + AbstractSocketAppender.DEFAULT_PORT + ": connection closed"); + inOrder.verify(syncAppender).addInfo("shutting down"); + } + + @Test + public void reconnectsAfterWaitingFollowingSslHandshakeFailure() throws Exception { + + // given + InstrumentedSocketAppender syncAppender = newAppenderDispatchingOnCallingThread(); + syncAppender.setRemoteHost("127.0.0.1"); + syncAppender.setHandshakeFailureDelay(1); + mockOneSuccessfulSocketConnection(); + doThrow(new SSLHandshakeException("handshake failed")) + .when(objectWriterFactory).newAutoFlushingObjectWriter(any(OutputStream.class)); + + // when + syncAppender.start(); + + // then: after the wait it asks the connector for a new connection + verify(socketConnector, times(2)).call(); + verify(socket).close(); + verify(syncAppender, never()).addInfo(contains("connection failed")); + verify(syncAppender).addInfo("remote peer 127.0.0.1:" + AbstractSocketAppender.DEFAULT_PORT + ": connection closed"); + } + + @Test + public void reportsInterruptedConnector() throws Exception { + + // given + appender.setLazy(true); + appender.start(); + + // when + appender.connectionFailed(socketConnector, new InterruptedException()); + + // then + verify(appender).addInfo("connector interrupted"); + } + + @Test + public void reportsRefusedConnection() throws Exception { + + // given + appender.setLazy(true); + appender.start(); + + // when + appender.connectionFailed(socketConnector, new ConnectException("Connection refused")); + + // then + verify(appender).addInfo("remote peer localhost:" + AbstractSocketAppender.DEFAULT_PORT + ": connection refused"); + } + + @Test + public void reportsOtherConnectionFailuresWithTheException() throws Exception { + + // given + appender.setLazy(true); + appender.start(); + IOException failure = new IOException("network is unreachable"); + + // when + appender.connectionFailed(socketConnector, failure); + + // then + verify(appender).addInfo("remote peer localhost:" + AbstractSocketAppender.DEFAULT_PORT + ": " + failure); + } + + @Test + public void hasDefaultProperties() throws Exception { + + // when + InstrumentedSocketAppender syncAppender = newAppenderDispatchingOnCallingThread(); + + // then + assertFalse(syncAppender.getLazy()); + assertNull(syncAppender.getRemoteHost()); + assertEquals(AbstractSocketAppender.DEFAULT_PORT, syncAppender.getPort()); + assertEquals(AbstractSocketAppender.DEFAULT_RECONNECTION_DELAY, syncAppender.getReconnectionDelay().getMilliseconds()); + assertEquals(AbstractSocketAppender.DEFAULT_QUEUE_SIZE, syncAppender.getQueueSize()); + assertEquals(100, syncAppender.getEventDelayLimit().getMilliseconds()); + } + + @Test + public void returnsConfiguredProperties() throws Exception { + + // given + Duration reconnectionDelay = new Duration(42); + Duration eventDelayLimit = new Duration(7); + + // when + appender.setLazy(true); + appender.setRemoteHost("some.host"); + appender.setPort(1234); + appender.setReconnectionDelay(reconnectionDelay); + appender.setQueueSize(5); + appender.setEventDelayLimit(eventDelayLimit); + + // then + assertTrue(appender.getLazy()); + assertEquals("some.host", appender.getRemoteHost()); + assertEquals(1234, appender.getPort()); + assertSame(reconnectionDelay, appender.getReconnectionDelay()); + assertEquals(5, appender.getQueueSize()); + assertSame(eventDelayLimit, appender.getEventDelayLimit()); + } + + /** + * Creates an appender whose context runs the dispatch task synchronously on + * the thread that submits it, so that {@code start()} returns only once the + * task has finished (the mock connector yields no socket unless stubbed). + */ + private InstrumentedSocketAppender newAppenderDispatchingOnCallingThread() { + inlineAppender = spy(new InstrumentedSocketAppender(preSerializationTransformer, queueFactory, objectWriterFactory, socketConnector)); + inlineAppender.setContext(new MockContext(new MockScheduledExecutorService())); + return inlineAppender; + } + private void awaitAtLeastOneEventToBeDispatched() throws IOException { verify(objectWriter, timeout(TIMEOUT)).write(anyString()); } From 1459a329111e77ffd5c36e032809401c68d2c1f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:23:22 +0000 Subject: [PATCH 37/79] test: cover the core.net appenders, connectors and syslog classes Brings AbstractSSLSocketAppender, SSLSocketAppenderBase, DefaultSocketConnector, LoginAuthenticator, QueueFactory, SocketConnectorBase, SyslogAppenderBase, SyslogConstants and SyslogOutputStream to full line and branch coverage. The tests pin: - SSL appenders: a set SSLConfiguration is used, and a context that cannot be created (unknown protocol) reports an error and does not start. - DefaultSocketConnector: it retries after the delay until connected, returns no socket (leaving the interrupt set) when interrupted after a failed attempt, and writes failures to the console when no handler is set. These tests use scripted mock socket factories rather than timing, so line 77's branches are covered on every run. - SocketConnectorBase: its test was @Ignore'd and broken (it never ran the connector). It is rewritten with deterministic tests: connect, no reuse, retries, interruption before, during and after an attempt, the console handler, the platform socket factory (loopback), awaitConnection timing out or being woken by the connection, and the initial/retry delays. - SyslogAppenderBase: the mandatory facility, connection failures (eager and lazy), charset, layout, maxMessageSize defaulting, capping and warnings, truncation, null layouts, send errors, stopped appenders, facility parsing, and the accessors. - SyslogOutputStream: one datagram per flush over loopback UDP, no empty datagrams, long messages, the send buffer size, and close/flush after close. - QueueFactory's minimum capacity, LoginAuthenticator's credentials and the SyslogConstants values. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../net/AbstractSSLSocketAppenderTest.java | 34 ++ .../core/net/DefaultSocketConnectorTest.java | 87 ++++ .../core/net/LoginAuthenticatorTest.java | 46 ++ .../logback/core/net/QueueFactoryTest.java | 58 +++ .../core/net/SSLSocketAppenderBaseTest.java | 34 ++ .../core/net/SocketConnectorBaseTest.java | 283 ++++++++---- .../core/net/SyslogAppenderBaseTest.java | 431 ++++++++++++++++++ .../logback/core/net/SyslogConstantsTest.java | 68 +++ .../core/net/SyslogOutputStreamTest.java | 131 ++++++ 9 files changed, 1094 insertions(+), 78 deletions(-) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/net/LoginAuthenticatorTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/net/QueueFactoryTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/net/SyslogConstantsTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/net/SyslogOutputStreamTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/AbstractSSLSocketAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/AbstractSSLSocketAppenderTest.java index 5b538a07f..e02d75233 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/AbstractSSLSocketAppenderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/AbstractSSLSocketAppenderTest.java @@ -15,13 +15,22 @@ */ package ch.qos.logback.core.net; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.security.NoSuchAlgorithmException; import org.junit.Before; import org.junit.Test; import ch.qos.logback.core.net.mock.MockContext; +import ch.qos.logback.core.net.ssl.SSLConfiguration; import ch.qos.logback.core.spi.PreSerializationTransformer; +import ch.qos.logback.core.status.Status; /** * Unit tests for {@link AbstractSSLSocketAppender}. @@ -49,6 +58,31 @@ public void testUsingDefaultConfig() throws Exception { appender.stop(); } + @Test + public void usesTheSslConfigurationThatWasSet() throws Exception { + SSLConfiguration ssl = new SSLConfiguration(); + + appender.setSsl(ssl); + + assertSame(ssl, appender.getSsl()); + } + + @Test + public void reportsErrorAndDoesNotStartWhenSslContextCannotBeCreated() throws Exception { + SSLConfiguration ssl = new SSLConfiguration(); + ssl.setProtocol("NO-SUCH-PROTOCOL"); + appender.setSsl(ssl); + + appender.start(); + + assertFalse(appender.isStarted()); + assertNull(appender.getSocketFactory()); + Status status = context.getLastStatus(); + assertEquals(Status.ERROR, status.getLevel()); + assertTrue(status.getThrowable() instanceof NoSuchAlgorithmException); + assertEquals(status.getThrowable().getMessage(), status.getMessage()); + } + private static class InstrumentedSSLSocketAppenderBase extends AbstractSSLSocketAppender { diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/DefaultSocketConnectorTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/DefaultSocketConnectorTest.java index 564af808d..dacf18f48 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/DefaultSocketConnectorTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/DefaultSocketConnectorTest.java @@ -15,7 +15,10 @@ */ package ch.qos.logback.core.net; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; import java.net.ConnectException; +import java.net.InetAddress; import java.net.ServerSocket; import java.net.Socket; import java.net.SocketAddress; @@ -29,17 +32,27 @@ import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import javax.net.SocketFactory; + import org.junit.After; import org.junit.Before; import org.junit.Test; import ch.qos.logback.core.net.SocketConnector.ExceptionHandler; import ch.qos.logback.core.net.server.ServerSocketUtil; +import ch.qos.logback.core.util.DelayStrategy; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.fail; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; /** * Unit tests for {@link DefaultSocketConnector}. @@ -72,6 +85,80 @@ public void tearDown() throws Exception { if (serverSocket != null) { serverSocket.close(); } + executor.shutdownNow(); + } + + @Test + public void retriesAfterTheDelayUntilConnected() throws Exception { + InetAddress address = InetAddress.getByAddress(new byte[] {127, 0, 0, 1}); + ConnectException failure = new ConnectException("refused"); + Socket socket = mock(Socket.class); + SocketFactory socketFactory = mock(SocketFactory.class); + when(socketFactory.createSocket(address, 4560)).thenThrow(failure).thenReturn(socket); + DelayStrategy delayStrategy = mock(DelayStrategy.class); + ExceptionHandler handler = mock(ExceptionHandler.class); + DefaultSocketConnector dsc = new DefaultSocketConnector(address, 4560, delayStrategy); + dsc.setSocketFactory(socketFactory); + dsc.setExceptionHandler(handler); + + assertSame(socket, dsc.call()); + + verify(handler).connectionFailed(dsc, failure); + verify(delayStrategy, times(1)).nextDelay(); + verify(socketFactory, times(2)).createSocket(address, 4560); + } + + @Test + public void givesUpWithoutSocketWhenInterruptedAfterFailedAttempt() throws Exception { + InetAddress address = InetAddress.getByAddress(new byte[] {127, 0, 0, 1}); + final ConnectException failure = new ConnectException("refused"); + SocketFactory socketFactory = mock(SocketFactory.class); + when(socketFactory.createSocket(address, 4560)).thenAnswer(invocation -> { + Thread.currentThread().interrupt(); + throw failure; + }); + DelayStrategy delayStrategy = mock(DelayStrategy.class); + ExceptionHandler handler = mock(ExceptionHandler.class); + DefaultSocketConnector dsc = new DefaultSocketConnector(address, 4560, delayStrategy); + dsc.setSocketFactory(socketFactory); + dsc.setExceptionHandler(handler); + + Socket socket; + boolean interrupted; + try { + socket = dsc.call(); + } finally { + interrupted = Thread.interrupted(); + } + + assertNull(socket); + // the interrupt status is left for the caller + assertTrue(interrupted); + verify(handler).connectionFailed(dsc, failure); + verify(socketFactory, times(1)).createSocket(address, 4560); + verifyNoInteractions(delayStrategy); + } + + @Test + public void reportsFailuresOnTheConsoleWithoutExceptionHandler() throws Exception { + InetAddress address = InetAddress.getByAddress(new byte[] {127, 0, 0, 1}); + Socket socket = mock(Socket.class); + SocketFactory socketFactory = mock(SocketFactory.class); + when(socketFactory.createSocket(address, 4560)) + .thenThrow(new ConnectException("refused (test)")).thenReturn(socket); + DefaultSocketConnector dsc = new DefaultSocketConnector(address, 4560, mock(DelayStrategy.class)); + dsc.setSocketFactory(socketFactory); + + PrintStream originalOut = System.out; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + System.setOut(new PrintStream(out, true, "UTF-8")); + try { + assertSame(socket, dsc.call()); + } finally { + System.setOut(originalOut); + } + + assertTrue(out.toString("UTF-8").contains("java.net.ConnectException: refused (test)")); } @Test diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/LoginAuthenticatorTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/LoginAuthenticatorTest.java new file mode 100644 index 000000000..26549d7a9 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/net/LoginAuthenticatorTest.java @@ -0,0 +1,46 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.net; + +import static org.junit.Assert.assertEquals; + +import javax.mail.PasswordAuthentication; + +import org.junit.Test; + +/** + * Unit tests for {@link LoginAuthenticator}. + */ +public class LoginAuthenticatorTest { + + @Test + public void authenticatesWithTheGivenCredentials() { + LoginAuthenticator authenticator = new LoginAuthenticator("someUser", "somePassword"); + + PasswordAuthentication authentication = authenticator.getPasswordAuthentication(); + + assertEquals("someUser", authentication.getUserName()); + assertEquals("somePassword", authentication.getPassword()); + } + + @Test + public void keepsTheGivenCredentials() { + LoginAuthenticator authenticator = new LoginAuthenticator("someUser", "somePassword"); + + assertEquals("someUser", authenticator.username); + assertEquals("somePassword", authenticator.password); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/QueueFactoryTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/QueueFactoryTest.java new file mode 100644 index 000000000..4779849e7 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/net/QueueFactoryTest.java @@ -0,0 +1,58 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.net; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; + +import java.util.concurrent.LinkedBlockingDeque; + +import org.junit.Test; + +/** + * Unit tests for {@link QueueFactory}. + */ +public class QueueFactoryTest { + + private final QueueFactory queueFactory = new QueueFactory(); + + @Test + public void createsEmptyDequeWithTheGivenCapacity() { + LinkedBlockingDeque deque = queueFactory.newLinkedBlockingDeque(5); + + assertEquals(0, deque.size()); + assertEquals(5, deque.remainingCapacity()); + } + + @Test + public void usesCapacityOfOneWhenGivenCapacityIsZero() { + LinkedBlockingDeque deque = queueFactory.newLinkedBlockingDeque(0); + + assertEquals(1, deque.remainingCapacity()); + } + + @Test + public void usesCapacityOfOneWhenGivenCapacityIsNegative() { + LinkedBlockingDeque deque = queueFactory.newLinkedBlockingDeque(-3); + + assertEquals(1, deque.remainingCapacity()); + } + + @Test + public void createsNewDequeOnEachCall() { + assertNotSame(queueFactory.newLinkedBlockingDeque(1), queueFactory.newLinkedBlockingDeque(1)); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/SSLSocketAppenderBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/SSLSocketAppenderBaseTest.java index a7bb959b6..1e03fff20 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/SSLSocketAppenderBaseTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/SSLSocketAppenderBaseTest.java @@ -15,13 +15,22 @@ */ package ch.qos.logback.core.net; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.security.NoSuchAlgorithmException; import org.junit.Before; import org.junit.Test; import ch.qos.logback.core.net.mock.MockContext; +import ch.qos.logback.core.net.ssl.SSLConfiguration; import ch.qos.logback.core.spi.PreSerializationTransformer; +import ch.qos.logback.core.status.Status; /** * Unit tests for {@link SSLSocketAppenderBase}. @@ -47,6 +56,31 @@ public void testUsingDefaultConfig() throws Exception { assertNotNull(appender.getSocketFactory()); } + @Test + public void usesTheSslConfigurationThatWasSet() throws Exception { + SSLConfiguration ssl = new SSLConfiguration(); + + appender.setSsl(ssl); + + assertSame(ssl, appender.getSsl()); + } + + @Test + public void reportsErrorAndDoesNotStartWhenSslContextCannotBeCreated() throws Exception { + SSLConfiguration ssl = new SSLConfiguration(); + ssl.setProtocol("NO-SUCH-PROTOCOL"); + appender.setSsl(ssl); + + appender.start(); + + assertFalse(appender.isStarted()); + assertNull(appender.getSocketFactory()); + Status status = context.getLastStatus(); + assertEquals(Status.ERROR, status.getLevel()); + assertTrue(status.getThrowable() instanceof NoSuchAlgorithmException); + assertEquals(status.getThrowable().getMessage(), status.getMessage()); + } + private static class InstrumentedSSLSocketAppenderBase extends SSLSocketAppenderBase { diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/SocketConnectorBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/SocketConnectorBaseTest.java index 8d8d1cd1b..cf13d31ff 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/SocketConnectorBaseTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/SocketConnectorBaseTest.java @@ -15,136 +15,263 @@ */ package ch.qos.logback.core.net; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.lang.reflect.Field; import java.net.ConnectException; +import java.net.InetAddress; import java.net.ServerSocket; import java.net.Socket; -import java.net.SocketAddress; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.Condition; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; + +import javax.net.SocketFactory; import org.junit.After; import org.junit.Before; -import org.junit.Ignore; import org.junit.Test; import ch.qos.logback.core.net.SocketConnector.ExceptionHandler; -import ch.qos.logback.core.net.server.ServerSocketUtil; +import ch.qos.logback.core.net.SocketConnectorBase.DelayStrategy; /** * Unit tests for {@link SocketConnectorBase}. * * @author Carl Harris */ -@Ignore() public class SocketConnectorBaseTest { - private static final int DELAY = 1000; + /** Upper bound for a test that involves another thread; never reached when it passes. */ + private static final long TIMEOUT = 10000; + + private static final int PORT = 4560; - private MockExceptionHandler exceptionHandler = new MockExceptionHandler(); + private final SocketFactory socketFactory = mock(SocketFactory.class); + private final ExceptionHandler exceptionHandler = mock(ExceptionHandler.class); + private final DelayStrategy noDelay = mock(DelayStrategy.class); + private InetAddress address; + private Socket socket; private ServerSocket serverSocket; - private SocketConnectorBase connector; @Before public void setUp() throws Exception { - serverSocket = ServerSocketUtil.createServerSocket(); - connector = new SocketConnectorBase(serverSocket.getInetAddress(), - serverSocket.getLocalPort(), 0, DELAY); - connector.setExceptionHandler(exceptionHandler); + address = InetAddress.getByAddress(new byte[] {127, 0, 0, 1}); + socket = new Socket(); } @After public void tearDown() throws Exception { + // clear an interrupt a failed test may have left behind + Thread.interrupted(); + socket.close(); if (serverSocket != null) { serverSocket.close(); } } @Test - public void testConnect() throws Exception { - Thread thread = new Thread(); - thread.start(); - Socket socket = connector.awaitConnection(2 * DELAY); - assertNotNull(socket); - thread.join(DELAY); - assertFalse(thread.isAlive()); - socket.close(); + public void connectsToTheConfiguredAddressAndPort() throws Exception { + when(socketFactory.createSocket(address, PORT)).thenReturn(socket); + SocketConnectorBase connector = newConnector(); + + connector.run(); + + assertSame(socket, connector.awaitConnection(0)); + verify(noDelay, times(1)).nextDelay(); + verifyNoInteractions(exceptionHandler); } @Test - public void testConnectionFails() throws Exception { - serverSocket.close(); - Thread thread = new Thread(); - thread.start(); - Socket socket = connector.awaitConnection(2 * DELAY); - assertNull(socket); - Exception lastException = exceptionHandler.awaitConnectionFailed(DELAY); - assertTrue(lastException instanceof ConnectException); - assertTrue(thread.isAlive()); - thread.interrupt(); - thread.join(4 * DELAY); - assertFalse(thread.isAlive()); + public void cannotBeReusedOnceConnected() throws Exception { + when(socketFactory.createSocket(address, PORT)).thenReturn(socket); + SocketConnectorBase connector = newConnector(); + connector.run(); + + IllegalStateException e = assertThrows(IllegalStateException.class, connector::run); + + assertEquals("connector cannot be reused", e.getMessage()); + verify(socketFactory, times(1)).createSocket(address, PORT); } @Test - public void testConnectEventually() throws Exception { - serverSocket.close(); - - Thread thread = new Thread(); - thread.start(); - Socket socket = connector.awaitConnection(2 * DELAY); - assertNull(socket); - Exception lastException = exceptionHandler.awaitConnectionFailed(DELAY); - assertTrue(lastException instanceof ConnectException); - assertTrue(thread.isAlive()); - - // now rebind to the same local address - SocketAddress address = serverSocket.getLocalSocketAddress(); - serverSocket = new ServerSocket(); - serverSocket.setReuseAddress(true); - serverSocket.bind(address); - - // now we should be able to connect - socket = connector.awaitConnection(2 * DELAY); - assertNotNull(socket); - thread.join(DELAY); - assertFalse(thread.isAlive()); - socket.close(); + public void retriesAfterFailedAttemptUntilConnected() throws Exception { + ConnectException failure = new ConnectException("refused"); + when(socketFactory.createSocket(address, PORT)).thenThrow(failure).thenReturn(socket); + SocketConnectorBase connector = newConnector(); + + connector.run(); + + assertSame(socket, connector.awaitConnection(0)); + verify(exceptionHandler).connectionFailed(connector, failure); + verify(noDelay, times(2)).nextDelay(); } - private static class MockExceptionHandler implements ExceptionHandler { + @Test + public void reportsInterruptionWhileWaitingToConnect() throws Exception { + DelayStrategy interruptingDelay = () -> { + Thread.currentThread().interrupt(); + return 1; + }; + SocketConnectorBase connector = new SocketConnectorBase(address, PORT, interruptingDelay); + connector.setSocketFactory(socketFactory); + connector.setExceptionHandler(exceptionHandler); - private final Lock lock = new ReentrantLock(); - private final Condition failedCondition = lock.newCondition(); + connector.run(); - private Exception lastException; + verify(exceptionHandler).connectionFailed(eq(connector), any(InterruptedException.class)); + verifyNoInteractions(socketFactory); + assertNull(connector.awaitConnection(0)); + } - public void connectionFailed(SocketConnector connector, Exception ex) { - lastException = ex; + @Test + public void doesNotConnectWhenAlreadyInterrupted() throws Exception { + SocketConnectorBase connector = newConnector(); + + boolean interrupted; + Thread.currentThread().interrupt(); + try { + connector.run(); + } finally { + interrupted = Thread.interrupted(); } - public Exception awaitConnectionFailed(long delay) - throws InterruptedException { - lock.lock(); - try { - boolean timeout = false; - while (lastException == null && !timeout) { - timeout = !failedCondition.await(delay, TimeUnit.MILLISECONDS); - } - return lastException; - } - finally { - lock.unlock(); + assertTrue(interrupted); + verifyNoInteractions(noDelay, socketFactory, exceptionHandler); + } + + @Test + public void stopsRetryingWhenInterruptedAfterFailedAttempt() throws Exception { + final ConnectException failure = new ConnectException("refused"); + when(socketFactory.createSocket(address, PORT)).thenAnswer(invocation -> { + Thread.currentThread().interrupt(); + throw failure; + }); + SocketConnectorBase connector = newConnector(); + + boolean interrupted; + try { + connector.run(); + } finally { + interrupted = Thread.interrupted(); + } + + assertTrue(interrupted); + verify(exceptionHandler).connectionFailed(connector, failure); + verify(socketFactory, times(1)).createSocket(address, PORT); + verify(noDelay, times(1)).nextDelay(); + } + + @Test + public void reportsFailuresOnTheConsoleWithoutExceptionHandler() throws Exception { + when(socketFactory.createSocket(address, PORT)) + .thenThrow(new ConnectException("refused (test)")).thenReturn(socket); + SocketConnectorBase connector = new SocketConnectorBase(address, PORT, noDelay); + connector.setSocketFactory(socketFactory); + + PrintStream originalOut = System.out; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + System.setOut(new PrintStream(out, true, "UTF-8")); + try { + connector.run(); + } finally { + System.setOut(originalOut); + } + + assertTrue(out.toString("UTF-8").contains("java.net.ConnectException: refused (test)")); + assertSame(socket, connector.awaitConnection(0)); + } + + @Test(timeout = TIMEOUT) + public void usesThePlatformSocketFactoryByDefault() throws Exception { + serverSocket = new ServerSocket(0, 1, address); + SocketConnectorBase connector = new SocketConnectorBase(address, serverSocket.getLocalPort(), noDelay); + connector.setExceptionHandler(exceptionHandler); + + connector.run(); + + Socket connected = connector.awaitConnection(0); + try { + assertTrue(connected.isConnected()); + assertEquals(serverSocket.getLocalPort(), connected.getPort()); + } finally { + connected.close(); + } + verifyNoInteractions(exceptionHandler); + } + + @Test + public void awaitConnectionReturnsNullWhenNotConnectedInTime() throws Exception { + SocketConnectorBase connector = newConnector(); + + assertNull(connector.awaitConnection(1)); + } + + @Test(timeout = TIMEOUT) + public void awaitConnectionReturnsTheSocketOnceConnected() throws Exception { + final Thread awaitingThread = Thread.currentThread(); + // connect only once the awaiting thread waits for the connection + when(socketFactory.createSocket(address, PORT)).thenAnswer(invocation -> { + while (awaitingThread.getState() != Thread.State.TIMED_WAITING) { + Thread.yield(); } + return socket; + }); + SocketConnectorBase connector = newConnector(); + Thread connectorThread = new Thread(connector::run); + connectorThread.start(); + + Socket connected; + try { + connected = connector.awaitConnection(); + } finally { + connectorThread.join(); } + assertSame(socket, connected); + } + + @Test + public void waitsTheInitialDelayFirstAndTheRetryDelayAfterwards() throws Exception { + SocketConnectorBase connector = new SocketConnectorBase(address, PORT, 3, 7); + + DelayStrategy delayStrategy = delayStrategyOf(connector); + + assertEquals(3, delayStrategy.nextDelay()); + assertEquals(7, delayStrategy.nextDelay()); + assertEquals(7, delayStrategy.nextDelay()); + } + + @Test + public void callReturnsNoSocket() throws Exception { + SocketConnectorBase connector = newConnector(); + + assertNull(connector.call()); + verifyNoInteractions(socketFactory); + } + + private SocketConnectorBase newConnector() { + SocketConnectorBase connector = new SocketConnectorBase(address, PORT, noDelay); + connector.setSocketFactory(socketFactory); + connector.setExceptionHandler(exceptionHandler); + return connector; + } + + private static DelayStrategy delayStrategyOf(SocketConnectorBase connector) throws Exception { + Field field = SocketConnectorBase.class.getDeclaredField("delayStrategy"); + field.setAccessible(true); + return (DelayStrategy) field.get(connector); } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/SyslogAppenderBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/SyslogAppenderBaseTest.java index 864af161d..7b0068888 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/SyslogAppenderBaseTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/SyslogAppenderBaseTest.java @@ -15,12 +15,54 @@ */ package ch.qos.logback.core.net; +import org.junit.After; +import org.junit.Before; import org.junit.Test; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.SocketException; +import java.net.UnknownHostException; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.Layout; +import ch.qos.logback.core.LayoutBase; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; + +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; public class SyslogAppenderBaseTest { + private static final Charset UTF_8 = Charset.forName("UTF-8"); + + private final Context context = new ContextBase(); + private final TestSyslogAppender appender = new TestSyslogAppender(); + + @Before + public void setUp() { + appender.setContext(context); + appender.setSyslogHost("localhost"); + appender.setFacility("USER"); + appender.setCharset(UTF_8); + } + + @After + public void tearDown() { + appender.stop(); + } + @Test public void testFacilityStringToint() throws InterruptedException { @@ -49,4 +91,393 @@ public void testFacilityStringToint() throws InterruptedException assertEquals(SyslogConstants.LOG_LOCAL6, SyslogAppenderBase.facilityStringToint("LOCAL6")); assertEquals(SyslogConstants.LOG_LOCAL7, SyslogAppenderBase.facilityStringToint("LOCAL7")); } + + @Test + public void facilityStringIsCaseInsensitive() { + assertEquals(SyslogConstants.LOG_LOCAL7, SyslogAppenderBase.facilityStringToint("local7")); + } + + @Test + public void rejectsUnknownFacilityString() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> SyslogAppenderBase.facilityStringToint("NOPE")); + + assertEquals("NOPE is not a valid syslog facility string", e.getMessage()); + } + + @Test + public void trimsTheFacility() { + appender.setFacility(" LOCAL0 "); + + assertEquals("LOCAL0", appender.getFacility()); + } + + @Test + public void acceptsNullFacility() { + appender.setFacility(null); + + assertNull(appender.getFacility()); + } + + @Test + public void failsToStartWithoutFacility() { + appender.setFacility(null); + + appender.start(); + + assertFalse(appender.isStarted()); + assertStatus(Status.ERROR, "The Facility option is mandatory"); + } + + @Test + public void startsWithFacilityAndConnection() { + appender.start(); + + assertTrue(appender.isStarted()); + assertEquals(1, appender.outputStreamsCreated); + new StatusChecker(context).assertIsWarningOrErrorFree(); + } + + @Test + public void failsToStartWhenSyslogHostIsUnknown() { + UnknownHostException failure = new UnknownHostException("no.such.host"); + appender.unknownHost = failure; + + appender.start(); + + assertFalse(appender.isStarted()); + assertSame(failure, assertStatus(Status.ERROR, "Could not create SyslogWriter").getThrowable()); + } + + @Test + public void failsToStartWhenDatagramSocketCannotBeBound() { + SocketException failure = new SocketException("no free port"); + appender.socketFailure = failure; + + appender.start(); + + assertFalse(appender.isStarted()); + assertSame(failure, assertStatus(Status.WARN, + "Failed to bind to a random datagram socket. Will try to reconnect later.").getThrowable()); + } + + @Test + public void defaultsCharsetToThePlatformCharset() { + appender.setCharset(null); + + appender.start(); + + assertEquals(Charset.defaultCharset(), appender.getCharset()); + } + + @Test + public void encodesMessagesWithTheConfiguredCharset() { + Charset charset = Charset.forName("UTF-16BE"); + appender.setCharset(charset); + appender.start(); + + appender.append("héllo"); + + assertSame(charset, appender.getCharset()); + assertEquals(1, appender.stream.messages.size()); + assertArrayEquals("héllo".getBytes(charset), appender.stream.messages.get(0)); + } + + @Test + public void buildsLayoutOnStart() { + appender.start(); + + assertEquals(1, appender.layoutsBuilt); + assertTrue(appender.getLayout() instanceof EchoLayout); + } + + @Test + public void keepsExistingLayoutOnStart() { + Layout layout = new EchoLayout(); + appender.layout = layout; + + appender.start(); + + assertEquals(0, appender.layoutsBuilt); + assertSame(layout, appender.getLayout()); + } + + @Test + public void ignoresLayoutSetDirectly() { + appender.setLayout(new EchoLayout()); + + assertNull(appender.getLayout()); + assertStatus(Status.WARN, "The layout of a SyslogAppender cannot be set directly. See also " + + SyslogAppenderBase.SYSLOG_LAYOUT_URL); + } + + @Test + public void defaultsMaxMessageSizeToSystemDatagramSize() { + appender.sendBufferSize = 1000; + + appender.start(); + + assertEquals(1000, appender.getMaxMessageSize()); + assertStatus(Status.INFO, "Defaulting maxMessageSize to [1000]"); + } + + @Test + public void limitsDefaultMaxMessageSize() { + appender.sendBufferSize = 100000; + + appender.start(); + + assertEquals(SyslogAppenderBase.MAX_MESSAGE_SIZE_LIMIT, appender.getMaxMessageSize()); + } + + @Test + public void warnsWhenMaxMessageSizeExceedsSystemDatagramSize() { + appender.sendBufferSize = 1000; + appender.setMaxMessageSize(2000); + + appender.start(); + + assertTrue(appender.isStarted()); + assertEquals(2000, appender.getMaxMessageSize()); + assertStatus(Status.WARN, + "maxMessageSize of [2000] is larger than the system defined datagram size of [1000]."); + assertStatus(Status.WARN, "This may result in dropped logs."); + } + + @Test + public void keepsMaxMessageSizeWithinSystemDatagramSize() { + appender.sendBufferSize = 1000; + appender.setMaxMessageSize(1000); + + appender.start(); + + assertTrue(appender.isStarted()); + assertEquals(1000, appender.getMaxMessageSize()); + new StatusChecker(context).assertIsWarningOrErrorFree(); + } + + @Test + public void writesAndFlushesEachEvent() { + appender.start(); + + appender.append("first"); + appender.append("second"); + + assertEquals(2, appender.stream.messages.size()); + assertEquals("first", appender.stream.message(0)); + assertEquals("second", appender.stream.message(1)); + } + + @Test + public void truncatesMessagesLongerThanMaxMessageSize() { + appender.setMaxMessageSize(5); + appender.start(); + + appender.append("abcdefgh"); + appender.append("abcde"); + + assertEquals("abcde", appender.stream.message(0)); + assertEquals("abcde", appender.stream.message(1)); + } + + @Test + public void skipsEventsTheLayoutRendersAsNull() { + appender.start(); + + appender.append(null); + + assertEquals(0, appender.stream.messages.size()); + assertEquals(0, appender.stream.pending.size()); + } + + @Test + public void reportsErrorWhenSendingFails() { + appender.start(); + IOException failure = new IOException("send failed"); + appender.stream.flushFailure = failure; + + appender.append("event"); + + assertSame(failure, assertStatus(Status.ERROR, "Failed to send diagram to localhost").getThrowable()); + } + + @Test + public void ignoresEventsWhenStopped() { + appender.start(); + appender.stop(); + + appender.append("event"); + + assertTrue(appender.stream.closed); + assertEquals(0, appender.stream.messages.size()); + assertEquals(0, appender.stream.pending.size()); + } + + @Test + public void lazyAppenderConnectsOnFirstEvent() { + appender.setLazy(true); + + appender.start(); + + assertTrue(appender.isStarted()); + assertEquals(0, appender.outputStreamsCreated); + + appender.append("first"); + appender.append("second"); + + assertEquals(1, appender.outputStreamsCreated); + assertEquals("first", appender.stream.message(0)); + assertEquals("second", appender.stream.message(1)); + } + + @Test + public void lazyAppenderDropsEventsWhenItCannotConnect() { + appender.setLazy(true); + appender.socketFailure = new SocketException("no free port"); + appender.start(); + + appender.append("first"); + appender.append("second"); + + assertTrue(appender.isStarted()); + assertNull(appender.stream); + assertEquals(1, appender.outputStreamsCreated); + assertStatus(Status.WARN, "Failed to bind to a random datagram socket. Will try to reconnect later."); + } + + @Test + public void hasDefaultProperties() { + TestSyslogAppender unconfigured = new TestSyslogAppender(); + + assertEquals(SyslogConstants.SYSLOG_PORT, unconfigured.getPort()); + assertFalse(unconfigured.getLazy()); + assertEquals(0, unconfigured.getMaxMessageSize()); + assertNull(unconfigured.getSyslogHost()); + assertNull(unconfigured.getSuffixPattern()); + assertNull(unconfigured.getCharset()); + assertNull(unconfigured.getLayout()); + } + + @Test + public void returnsConfiguredProperties() { + Charset charset = Charset.forName("US-ASCII"); + + appender.setSyslogHost("syslog.example.com"); + appender.setPort(1514); + appender.setLazy(true); + appender.setMaxMessageSize(1234); + appender.setSuffixPattern("%msg"); + appender.setCharset(charset); + + assertEquals("syslog.example.com", appender.getSyslogHost()); + assertEquals(1514, appender.getPort()); + assertTrue(appender.getLazy()); + assertEquals(1234, appender.getMaxMessageSize()); + assertEquals("%msg", appender.getSuffixPattern()); + assertSame(charset, appender.getCharset()); + } + + private Status assertStatus(int level, String message) { + for (Status status : context.getStatusManager().getCopyOfStatusList()) { + if (status.getLevel() == level && message.equals(status.getMessage())) { + return status; + } + } + fail("no status of level " + level + " with message: " + message); + return null; + } + + /** + * A syslog appender whose output stream records what it is sent (or cannot + * be created), with an {@link EchoLayout} as its default layout. + */ + private static class TestSyslogAppender extends SyslogAppenderBase { + + UnknownHostException unknownHost; + SocketException socketFailure; + int sendBufferSize = 1000; + int outputStreamsCreated; + int layoutsBuilt; + RecordingSyslogOutputStream stream; + + @Override + public SyslogOutputStream createOutputStream() throws UnknownHostException, SocketException { + outputStreamsCreated++; + if (unknownHost != null) { + throw unknownHost; + } + if (socketFailure != null) { + throw socketFailure; + } + stream = new RecordingSyslogOutputStream(sendBufferSize); + return stream; + } + + @Override + public Layout buildLayout() { + layoutsBuilt++; + return new EchoLayout(); + } + + @Override + public int getSeverityForEvent(Object eventObject) { + return SyslogConstants.INFO_SEVERITY; + } + } + + /** Renders an event as itself. */ + private static class EchoLayout extends LayoutBase { + + @Override + public String doLayout(String event) { + return event; + } + } + + /** + * Records each flushed message instead of sending it, and reports the given + * size as the system's datagram size. + */ + private static class RecordingSyslogOutputStream extends SyslogOutputStream { + + final ByteArrayOutputStream pending = new ByteArrayOutputStream(); + final List messages = new ArrayList(); + final int sendBufferSize; + IOException flushFailure; + boolean closed; + + RecordingSyslogOutputStream(int sendBufferSize) throws UnknownHostException, SocketException { + super("127.0.0.1", SyslogConstants.SYSLOG_PORT); + this.sendBufferSize = sendBufferSize; + } + + @Override + public void write(byte[] byteArray, int offset, int len) { + pending.write(byteArray, offset, len); + } + + @Override + public void flush() throws IOException { + if (flushFailure != null) { + throw flushFailure; + } + messages.add(pending.toByteArray()); + pending.reset(); + } + + @Override + int getSendBufferSize() { + return sendBufferSize; + } + + @Override + public void close() { + closed = true; + super.close(); + } + + String message(int index) { + return new String(messages.get(index), UTF_8); + } + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/SyslogConstantsTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/SyslogConstantsTest.java new file mode 100644 index 000000000..d6a1c3344 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/net/SyslogConstantsTest.java @@ -0,0 +1,68 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.net; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import org.junit.Test; + +/** + * Unit tests for {@link SyslogConstants}. + */ +public class SyslogConstantsTest { + + @Test + public void isInstantiable() { + // a public class with the implicit public constructor + assertNotNull(new SyslogConstants()); + } + + @Test + public void usesTheStandardSyslogPort() { + assertEquals(514, SyslogConstants.SYSLOG_PORT); + } + + @Test + public void severitiesAreTheRfc3164Codes() { + assertEquals(0, SyslogConstants.EMERGENCY_SEVERITY); + assertEquals(1, SyslogConstants.ALERT_SEVERITY); + assertEquals(2, SyslogConstants.CRITICAL_SEVERITY); + assertEquals(3, SyslogConstants.ERROR_SEVERITY); + assertEquals(4, SyslogConstants.WARNING_SEVERITY); + assertEquals(5, SyslogConstants.NOTICE_SEVERITY); + assertEquals(6, SyslogConstants.INFO_SEVERITY); + assertEquals(7, SyslogConstants.DEBUG_SEVERITY); + } + + @Test + public void facilitiesAreTheRfc3164CodesTimesEight() { + // the facility part of PRI is precomputed as (facility code * 8) + int[] facilities = { + SyslogConstants.LOG_KERN, SyslogConstants.LOG_USER, SyslogConstants.LOG_MAIL, + SyslogConstants.LOG_DAEMON, SyslogConstants.LOG_AUTH, SyslogConstants.LOG_SYSLOG, + SyslogConstants.LOG_LPR, SyslogConstants.LOG_NEWS, SyslogConstants.LOG_UUCP, + SyslogConstants.LOG_CRON, SyslogConstants.LOG_AUTHPRIV, SyslogConstants.LOG_FTP, + SyslogConstants.LOG_NTP, SyslogConstants.LOG_AUDIT, SyslogConstants.LOG_ALERT, + SyslogConstants.LOG_CLOCK, SyslogConstants.LOG_LOCAL0, SyslogConstants.LOG_LOCAL1, + SyslogConstants.LOG_LOCAL2, SyslogConstants.LOG_LOCAL3, SyslogConstants.LOG_LOCAL4, + SyslogConstants.LOG_LOCAL5, SyslogConstants.LOG_LOCAL6, SyslogConstants.LOG_LOCAL7, + }; + for (int code = 0; code < facilities.length; code++) { + assertEquals("facility code " + code, code * 8, facilities[code]); + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/SyslogOutputStreamTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/SyslogOutputStreamTest.java new file mode 100644 index 000000000..20c321828 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/net/SyslogOutputStreamTest.java @@ -0,0 +1,131 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.net; + +import static org.junit.Assert.assertEquals; + +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.nio.charset.Charset; +import java.util.Arrays; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Unit tests for {@link SyslogOutputStream}. + */ +public class SyslogOutputStreamTest { + + private static final Charset UTF_8 = Charset.forName("UTF-8"); + + /** Upper bound for receiving a datagram sent over the loopback interface. */ + private static final int RECEIVE_TIMEOUT = 10000; + + private DatagramSocket receiver; + private SyslogOutputStream outputStream; + + @Before + public void setUp() throws Exception { + receiver = new DatagramSocket(0, InetAddress.getByAddress(new byte[] {127, 0, 0, 1})); + receiver.setSoTimeout(RECEIVE_TIMEOUT); + outputStream = new SyslogOutputStream("127.0.0.1", receiver.getLocalPort()); + } + + @After + public void tearDown() throws Exception { + outputStream.close(); + receiver.close(); + } + + @Test + public void returnsTheConfiguredPort() { + assertEquals(receiver.getLocalPort(), outputStream.getPort()); + } + + @Test + public void sendsTheBytesWrittenSinceTheLastFlushAsOneDatagram() throws Exception { + outputStream.write("hello".getBytes(UTF_8)); + outputStream.write('!'); + outputStream.flush(); + + outputStream.write("again".getBytes(UTF_8)); + outputStream.flush(); + + assertEquals("hello!", receive()); + assertEquals("again", receive()); + } + + @Test + public void sendsLongMessagesWholeAndStartsAfreshAfterwards() throws Exception { + byte[] longMessage = new byte[2000]; + Arrays.fill(longMessage, (byte) 'x'); + + outputStream.write(longMessage); + outputStream.flush(); + outputStream.write("short".getBytes(UTF_8)); + outputStream.flush(); + + assertEquals(new String(longMessage, UTF_8), receive()); + assertEquals("short", receive()); + } + + @Test + public void reportsTheSendBufferSizeOfItsDatagramSocket() throws Exception { + DatagramSocket defaultSocket = new DatagramSocket(); + try { + assertEquals(defaultSocket.getSendBufferSize(), outputStream.getSendBufferSize()); + } finally { + defaultSocket.close(); + } + } + + @Test + public void sendsNoDatagramWhenNothingWasWritten() throws Exception { + outputStream.flush(); + outputStream.write("message".getBytes(UTF_8)); + outputStream.flush(); + + // the first datagram is the message, not an empty one + assertEquals("message", receive()); + } + + @Test + public void ignoresFlushAfterClose() throws Exception { + outputStream.close(); + outputStream.write("dropped".getBytes(UTF_8)); + + // does not fail although the datagram socket is gone + outputStream.flush(); + } + + @Test + public void canBeClosedTwice() { + outputStream.close(); + + // does not fail although the datagram socket is gone + outputStream.close(); + } + + private String receive() throws Exception { + byte[] buffer = new byte[4096]; + DatagramPacket packet = new DatagramPacket(buffer, buffer.length); + receiver.receive(packet); + return new String(packet.getData(), packet.getOffset(), packet.getLength(), UTF_8); + } +} From 4b5b16f776ea644138941621429d881afa28d378 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:09:52 +0000 Subject: [PATCH 38/79] test: pin core.net behaviors that surviving mutants exposed Mutation checks of the core.net coverage work left three changes that no test noticed: - Dropping task.cancel(true) from AbstractSocketAppender.stop(): the dispatcher, blocked waiting for the next event, was never shown to shut down on stop. - Changing the default of the new handshakeFailureDelay field: the handshake-failure tests end the wait by interrupt or set a 1 ms delay, so the 30 s default the refactor must keep was unchecked. - Negating SyslogOutputStream's MAX_LEN check: both branches empty the buffer, so only which buffer is kept differs. Each now has a test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../core/net/AbstractSocketAppenderTest.java | 28 +++++++++++++++++ .../core/net/SyslogOutputStreamTest.java | 31 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/AbstractSocketAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/AbstractSocketAppenderTest.java index 8ed40805d..04c040781 100755 --- a/logback-android/src/test/java/ch/qos/logback/core/net/AbstractSocketAppenderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/AbstractSocketAppenderTest.java @@ -17,6 +17,7 @@ import java.io.IOException; import java.io.OutputStream; +import java.lang.reflect.Field; import java.net.ConnectException; import java.net.InetAddress; import java.net.Socket; @@ -722,6 +723,33 @@ public void reconnectsAfterWaitingFollowingSslHandshakeFailure() throws Exceptio verify(syncAppender).addInfo("remote peer 127.0.0.1:" + AbstractSocketAppender.DEFAULT_PORT + ": connection closed"); } + @Test + public void waitsTheDefaultReconnectionDelayAfterSslHandshakeFailure() throws Exception { + + // the wait is pinned by the two tests above; its length can be changed + // only by unit tests, so check the default directly + Field handshakeFailureDelay = AbstractSocketAppender.class.getDeclaredField("handshakeFailureDelay"); + handshakeFailureDelay.setAccessible(true); + + assertEquals(AbstractSocketAppender.DEFAULT_RECONNECTION_DELAY, handshakeFailureDelay.getInt(appender)); + } + + @Test + public void stopInterruptsTheDispatcherWaitingForEvents() throws Exception { + + // given + mockOneSuccessfulSocketConnection(); + appender.start(); + awaitStartOfEventDispatching(); + + // when + appender.stop(); + + // then: the dispatch task, blocked on the empty deque, is cancelled + verify(appender, timeout(TIMEOUT)).addInfo("shutting down"); + verify(socketConnector, times(1)).call(); + } + @Test public void reportsInterruptedConnector() throws Exception { diff --git a/logback-android/src/test/java/ch/qos/logback/core/net/SyslogOutputStreamTest.java b/logback-android/src/test/java/ch/qos/logback/core/net/SyslogOutputStreamTest.java index 20c321828..4549fb442 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/net/SyslogOutputStreamTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/net/SyslogOutputStreamTest.java @@ -16,7 +16,11 @@ package ch.qos.logback.core.net; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import java.io.ByteArrayOutputStream; +import java.lang.reflect.Field; import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.InetAddress; @@ -85,6 +89,27 @@ public void sendsLongMessagesWholeAndStartsAfreshAfterwards() throws Exception { assertEquals("short", receive()); } + @Test + public void reusesItsBufferUpTo1024BytesAndDiscardsLargerOnes() throws Exception { + ByteArrayOutputStream buffer = bufferOf(outputStream); + + outputStream.write(new byte[1024]); + outputStream.flush(); + + // a buffer of up to 1024 bytes is kept for the next message... + assertSame(buffer, bufferOf(outputStream)); + assertEquals(0, buffer.size()); + + outputStream.write(new byte[1025]); + outputStream.flush(); + + // ...a larger one is dropped, so that its memory can be reclaimed + assertNotSame(buffer, bufferOf(outputStream)); + assertEquals(0, bufferOf(outputStream).size()); + assertEquals(1024, receive().length()); + assertEquals(1025, receive().length()); + } + @Test public void reportsTheSendBufferSizeOfItsDatagramSocket() throws Exception { DatagramSocket defaultSocket = new DatagramSocket(); @@ -122,6 +147,12 @@ public void canBeClosedTwice() { outputStream.close(); } + private static ByteArrayOutputStream bufferOf(SyslogOutputStream stream) throws Exception { + Field field = SyslogOutputStream.class.getDeclaredField("baos"); + field.setAccessible(true); + return (ByteArrayOutputStream) field.get(stream); + } + private String receive() throws Exception { byte[] buffer = new byte[4096]; DatagramPacket packet = new DatagramPacket(buffer, buffer.length); From 72267247a8532e1bf6e69e269c2e0d6e93813de1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:33:39 +0000 Subject: [PATCH 39/79] fix: write OutputStreamAppender events through writeOut() so prudent mode locks the file OutputStreamAppender.subAppend() encoded the event and wrote the bytes itself, bypassing the protected writeOut() hook. FileAppender overrides writeOut() to implement prudent mode (lock the file, seek to its end, write, release the lock, preserving the thread's interrupt flag), so that code never ran: a prudent FileAppender/RollingFileAppender wrote without taking the file lock, and several processes writing the same file were not serialized as documented. subAppend() now calls writeOut(event), as upstream logback does since 1.3.0. Non-prudent appenders are unaffected: the default writeOut() encodes and writes exactly as before. OutputStreamAppenderTest.eventsAreWrittenThroughWriteOut and FileAppenderTest.prudentModeHoldsFileLockWhileEventIsWritten fail without this change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../logback/core/OutputStreamAppender.java | 6 +- .../core/OutputStreamAppenderTest.java | 61 +++++++++ .../core/appender/FileAppenderTest.java | 121 ++++++++++++++++++ 3 files changed, 186 insertions(+), 2 deletions(-) diff --git a/logback-android/src/main/java/ch/qos/logback/core/OutputStreamAppender.java b/logback-android/src/main/java/ch/qos/logback/core/OutputStreamAppender.java index cc1191e93..ea6b81cc7 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/OutputStreamAppender.java +++ b/logback-android/src/main/java/ch/qos/logback/core/OutputStreamAppender.java @@ -241,8 +241,10 @@ protected void subAppend(E event) { // are writing. It also prevents multiple threads from entering the same // converter. Converters assume that they are in a synchronized block. //lock.lock(); - byte[] byteArray = this.encoder.encode(event); - writeBytes(byteArray); + // Go through writeOut() so that subclasses can wrap the write, e.g. + // FileAppender in prudent mode, which locks the file around it + // (as upstream logback does since 1.3.0). + writeOut(event); } catch (IOException ioe) { // as soon as an exception occurs, move to non-started state diff --git a/logback-android/src/test/java/ch/qos/logback/core/OutputStreamAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/core/OutputStreamAppenderTest.java index 9e45a970a..9d752802f 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/OutputStreamAppenderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/OutputStreamAppenderTest.java @@ -17,13 +17,20 @@ import static junit.framework.Assert.assertTrue; +import static org.junit.Assert.assertEquals; import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import org.junit.After; import org.junit.Before; import org.junit.Test; +import ch.qos.logback.core.encoder.EncoderBase; import ch.qos.logback.core.encoder.LayoutWrappingEncoder; import ch.qos.logback.core.pattern.parser.SamplePatternLayout; @@ -121,4 +128,58 @@ public void headerFooterCheck(String fileHeader, String presentationHeader, Stri String emtptyIfNull(String s) { return s == null ? "" : s; } + + @Test + public void eventsAreWrittenThroughWriteOut() { + final List writtenOut = new ArrayList(); + OutputStreamAppender appender = new OutputStreamAppender() { + @Override + protected void writeOut(Object event) throws IOException { + writtenOut.add(event); + super.writeOut(event); + } + }; + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + appender.setContext(context); + appender.setEncoder(new Utf8LineEncoder()); + appender.setOutputStream(baos); + appender.start(); + + appender.doAppend("a"); + appender.doAppend("b"); + + assertEquals(Arrays.asList("a", "b"), writtenOut); + assertEquals("a\nb\n", utf8(baos)); + } + + static String utf8(ByteArrayOutputStream baos) { + return new String(baos.toByteArray(), StandardCharsets.UTF_8); + } + + /** + * Encodes each event as its string form in UTF-8 followed by a newline. + */ + static class Utf8LineEncoder extends EncoderBase { + byte[] header; + byte[] footer; + + @Override + public byte[] headerBytes() { + return header; + } + + @Override + public byte[] encode(Object event) { + return toUtf8(event + "\n"); + } + + @Override + public byte[] footerBytes() { + return footer; + } + + static byte[] toUtf8(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/appender/FileAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/core/appender/FileAppenderTest.java index 84066a135..62724bf40 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/appender/FileAppenderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/appender/FileAppenderTest.java @@ -21,12 +21,22 @@ import static org.junit.Assert.assertNull; import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.channels.FileLock; +import java.nio.channels.OverlappingFileLockException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import ch.qos.logback.core.encoder.EncoderBase; import ch.qos.logback.core.recovery.ResilientFileOutputStream; import ch.qos.logback.core.status.StatusChecker; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import ch.qos.logback.core.Appender; import ch.qos.logback.core.FileAppender; @@ -43,6 +53,9 @@ public class FileAppenderTest extends AbstractAppenderTest { private int diff = RandomUtil.getPositiveInt(); + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + protected Appender getAppender() { return new FileAppender(); } @@ -217,6 +230,114 @@ public void fileNameCollision() { checker.assertContainsMatch(Status.ERROR, "'File' option has the same value"); } + @Test + public void prudentModeHoldsFileLockWhileEventIsWritten() throws Exception { + File file = new File(tmp.getRoot(), "prudent-lock.log"); + LockProbingEncoder encoder = new LockProbingEncoder(file); + FileAppender fa = newFileAppender("prudent-lock", file, encoder); + fa.setPrudent(true); + fa.start(); + + fa.doAppend("hello"); + fa.stop(); + + assertEquals(Collections.singletonList(Boolean.TRUE), encoder.lockHeldDuringEncode); + assertEquals("hello\n", readUtf8(file)); + new StatusChecker(context).assertIsErrorFree(); + } + + @Test + public void nonPrudentModeDoesNotLockFile() throws Exception { + File file = new File(tmp.getRoot(), "non-prudent.log"); + LockProbingEncoder encoder = new LockProbingEncoder(file); + FileAppender fa = newFileAppender("non-prudent", file, encoder); + fa.start(); + + fa.doAppend("hello"); + fa.stop(); + + assertEquals(Collections.singletonList(Boolean.FALSE), encoder.lockHeldDuringEncode); + assertEquals("hello\n", readUtf8(file)); + } + + private FileAppender newFileAppender(String name, File file, EncoderBase encoder) { + FileAppender fa = new FileAppender(); + fa.setContext(context); + fa.setName(name); + fa.setEncoder(encoder); + fa.setFile(file.getAbsolutePath()); + return fa; + } + + static String readUtf8(File file) throws IOException { + RandomAccessFile raf = new RandomAccessFile(file, "r"); + try { + byte[] bytes = new byte[(int) raf.length()]; + raf.readFully(bytes); + return new String(bytes, StandardCharsets.UTF_8); + } finally { + raf.close(); + } + } + + /** + * Encodes each event as its string form in UTF-8 followed by a newline. + */ + static class Utf8LineEncoder extends EncoderBase { + @Override + public byte[] headerBytes() { + return null; + } + + @Override + public byte[] encode(Object event) { + return (event + "\n").getBytes(StandardCharsets.UTF_8); + } + + @Override + public byte[] footerBytes() { + return null; + } + } + + /** + * Records, for each encoded event, whether this JVM held a lock on the file + * while the event was being encoded (i.e. written). + */ + static class LockProbingEncoder extends Utf8LineEncoder { + final File file; + final List lockHeldDuringEncode = new ArrayList(); + + LockProbingEncoder(File file) { + this.file = file; + } + + @Override + public byte[] encode(Object event) { + lockHeldDuringEncode.add(isLockedByThisJvm()); + return super.encode(event); + } + + private boolean isLockedByThisJvm() { + try { + RandomAccessFile raf = new RandomAccessFile(file, "rw"); + try { + FileLock lock = raf.getChannel().tryLock(); + if (lock != null) { + lock.release(); + } + return false; + } catch (OverlappingFileLockException e) { + return true; + } finally { + raf.close(); + } + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + } + // helper class used to access protected fields class FileAppenderFriend extends FileAppender { public void append(E obj) { From ae1fb2b3b313c5f7651ce21ed039dfb38ed542fa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:44:51 +0000 Subject: [PATCH 40/79] test: cover ch.qos.logback.core appender bases, context and file/stream appenders Close the unit-test coverage gaps of the root ch.qos.logback.core classes (AppenderBase, UnsynchronizedAppenderBase, AsyncAppenderBase, BasicStatusManager, ConsoleAppender, ContextBase, CoreConstants, FileAppender, LayoutBase, LogbackException, NOPOutputStream, OutputStreamAppender, PropertyDefinerBase). The tests pin: - appender bases: the re-entrancy guard, capped "non started" warnings and append-failure errors, filter DENY/clear/copy; - AsyncAppenderBase: start without appender, no-op stop, single attached appender and the AppenderAttachable delegates, threshold and flush-time settings, a worker interrupted while the appender is started flushing its queue and exiting, and stop() reporting an error when interrupted while joining the worker (both deterministic: latches and self-interrupts, no timing); - OutputStreamAppender: missing encoder/stream errors, immediateFlush, header/footer/close/write IOException handling, null-stream edges; - FileAppender: missing/unopenable file and parent-dir errors, no collision map, releasing the file name on stop, buffer size, lazy open (once, on collision, on failure), and prudent writes (seek past another writer's data, interrupt flag restored, closed channel and invalidated lock, stream without a channel); - ContextBase: null status manager, null names, start/stop, shutdown hook removal including a shutdown already in progress (mocked Runtime); - CoreConstants.CORE_POOL_SIZE for JVM and Android java.version values. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../ch/qos/logback/core/AppenderBaseTest.java | 139 +++++++ .../logback/core/AsyncAppenderBaseTest.java | 237 ++++++++++++ .../logback/core/BasicStatusManagerTest.java | 24 ++ .../ch/qos/logback/core/ContextBaseTest.java | 119 ++++++ .../qos/logback/core/CoreConstantsTest.java | 126 +++++++ .../ch/qos/logback/core/LayoutBaseTest.java | 72 ++++ .../logback/core/LogbackExceptionTest.java | 43 +++ .../qos/logback/core/NOPOutputStreamTest.java | 36 ++ .../core/OutputStreamAppenderTest.java | 283 +++++++++++++++ .../logback/core/PropertyDefinerBaseTest.java | 33 ++ .../core/UnsynchronizedAppenderBaseTest.java | 189 ++++++++++ .../core/appender/ConsoleAppenderTest.java | 40 +++ .../core/appender/FileAppenderTest.java | 338 ++++++++++++++++++ 13 files changed, 1679 insertions(+) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/AppenderBaseTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/CoreConstantsTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/LayoutBaseTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/LogbackExceptionTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/NOPOutputStreamTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/PropertyDefinerBaseTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/UnsynchronizedAppenderBaseTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/core/AppenderBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/AppenderBaseTest.java new file mode 100644 index 000000000..36c93c0f8 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/AppenderBaseTest.java @@ -0,0 +1,139 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.filter.Filter; +import ch.qos.logback.core.spi.FilterReply; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; + +public class AppenderBaseTest { + + private final Context context = new ContextBase(); + private final StatusChecker statusChecker = new StatusChecker(context); + private final ReentrantListAppender appender = new ReentrantListAppender(); + + @Before + public void setUp() { + appender.setContext(context); + appender.setName("reentrant"); + } + + @Test + public void doAppendIgnoresEventsLoggedFromWithinAppend() { + appender.start(); + + appender.doAppend("outer"); + + // append() re-entered doAppend("inner") but the guard dropped it + assertEquals(Collections.singletonList("outer"), appender.events); + assertEquals(1, appender.reentrantCalls); + statusChecker.assertIsErrorFree(); + } + + @Test + public void guardIsReleasedAfterEachAppend() { + appender.start(); + + appender.doAppend("a"); + appender.doAppend("b"); + + assertEquals(Arrays.asList("a", "b"), appender.events); + } + + @Test + public void warningsAboutNonStartedAppenderAreCappedAtAllowedRepeats() { + for (int i = 0; i < AppenderBase.ALLOWED_REPEATS + 3; i++) { + appender.doAppend("event" + i); + } + + assertTrue(appender.events.isEmpty()); + assertEquals(AppenderBase.ALLOWED_REPEATS, + statusChecker.matchCount("Attempted to append to non started appender \\[reentrant\\].")); + assertEquals(Status.WARN, statusChecker.getHighestLevel(0)); + } + + @Test + public void clearAllFiltersRemovesDenyingFilter() { + appender.start(); + appender.addFilter(new FixedReplyFilter(FilterReply.DENY)); + + appender.doAppend("denied"); + assertTrue(appender.events.isEmpty()); + + appender.clearAllFilters(); + appender.doAppend("accepted"); + + assertEquals(Collections.singletonList("accepted"), appender.events); + assertTrue(appender.getCopyOfAttachedFiltersList().isEmpty()); + } + + @Test + public void getCopyOfAttachedFiltersListReturnsDetachedCopyOfFilters() { + Filter filter = new FixedReplyFilter(FilterReply.NEUTRAL); + appender.addFilter(filter); + + List> copy = appender.getCopyOfAttachedFiltersList(); + assertEquals(1, copy.size()); + assertSame(filter, copy.get(0)); + + copy.clear(); + assertEquals(1, appender.getCopyOfAttachedFiltersList().size()); + } + + /** + * Records appended events and, when it sees the "outer" event, logs a + * nested event through its own {@link #doAppend(Object)}. + */ + static class ReentrantListAppender extends AppenderBase { + final List events = new ArrayList(); + int reentrantCalls = 0; + + @Override + protected void append(String event) { + events.add(event); + if ("outer".equals(event)) { + reentrantCalls++; + doAppend("inner"); + } + } + } + + static class FixedReplyFilter extends Filter { + private final FilterReply reply; + + FixedReplyFilter(FilterReply reply) { + this.reply = reply; + } + + @Override + public FilterReply decide(String event) { + return reply; + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/AsyncAppenderBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/AsyncAppenderBaseTest.java index 821245898..c7087bc89 100755 --- a/logback-android/src/test/java/ch/qos/logback/core/AsyncAppenderBaseTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/AsyncAppenderBaseTest.java @@ -17,16 +17,21 @@ import ch.qos.logback.core.helpers.NOPAppender; import ch.qos.logback.core.read.ListAppender; +import ch.qos.logback.core.status.Status; import ch.qos.logback.core.testUtil.DelayingListAppender; import ch.qos.logback.core.status.StatusChecker; import ch.qos.logback.core.testUtil.NPEAppender; import org.junit.Before; import org.junit.Test; +import java.util.Arrays; +import java.util.Iterator; import java.util.concurrent.CountDownLatch; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; /** @@ -319,4 +324,236 @@ public void checkThatStartMethodIsIdempotent() { // a second invocation of start will cause a IllegalThreadStateException thrown by the asyncAppenderBase.worker thread asyncAppenderBase.start(); } + + @Test + public void startWithoutAttachedAppenderReportsErrorAndDoesNotStart() { + asyncAppenderBase.start(); + + assertFalse(asyncAppenderBase.isStarted()); + assertEquals(Thread.State.NEW, asyncAppenderBase.worker.getState()); + statusChecker.assertContainsMatch(Status.ERROR, "No attached appenders found."); + } + + @Test + public void stopOfNonStartedAppenderDoesNothing() { + asyncAppenderBase.addAppender(listAppender); + int statusCountBeforeStop = context.getStatusManager().getCount(); + + asyncAppenderBase.stop(); + + assertFalse(asyncAppenderBase.isStarted()); + assertEquals(statusCountBeforeStop, context.getStatusManager().getCount()); + // the attached appender is only stopped by a worker that actually ran + assertTrue(listAppender.isStarted()); + } + + @Test + public void discardingThresholdDefaultsToOneFifthOfQueueSizeOnStart() { + assertEquals(AsyncAppenderBase.UNDEFINED, asyncAppenderBase.getDiscardingThreshold()); + asyncAppenderBase.addAppender(listAppender); + asyncAppenderBase.setQueueSize(10); + + asyncAppenderBase.start(); + + assertEquals(2, asyncAppenderBase.getDiscardingThreshold()); + statusChecker.assertContainsMatch("Setting discardingThreshold to 2"); + asyncAppenderBase.stop(); + } + + @Test + public void explicitDiscardingThresholdIsKeptOnStart() { + asyncAppenderBase.addAppender(listAppender); + asyncAppenderBase.setQueueSize(10); + asyncAppenderBase.setDiscardingThreshold(7); + + asyncAppenderBase.start(); + + assertEquals(7, asyncAppenderBase.getDiscardingThreshold()); + statusChecker.assertContainsMatch("Setting discardingThreshold to 7"); + asyncAppenderBase.stop(); + } + + @Test + public void maxFlushTimeDefaultsToOneSecondAndIsSettable() { + assertEquals(AsyncAppenderBase.DEFAULT_MAX_FLUSH_TIME, asyncAppenderBase.getMaxFlushTime()); + asyncAppenderBase.setMaxFlushTime(123); + assertEquals(123, asyncAppenderBase.getMaxFlushTime()); + } + + @Test + public void neverBlockDefaultsToFalseAndIsSettable() { + assertFalse(asyncAppenderBase.isNeverBlock()); + asyncAppenderBase.setNeverBlock(true); + assertTrue(asyncAppenderBase.isNeverBlock()); + } + + @Test + public void onlyTheFirstAppenderIsAttached() { + ListAppender second = new ListAppender(); + second.setName("second"); + + asyncAppenderBase.addAppender(listAppender); + asyncAppenderBase.addAppender(second); + + statusChecker.assertContainsMatch(Status.INFO, "Attaching appender named \\[list\\] to AsyncAppender."); + statusChecker.assertContainsMatch(Status.WARN, "One and only one appender may be attached to AsyncAppender."); + statusChecker.assertContainsMatch(Status.WARN, "Ignoring additional appender named \\[second\\]"); + assertTrue(asyncAppenderBase.isAttached(listAppender)); + assertFalse(asyncAppenderBase.isAttached(second)); + assertSame(listAppender, asyncAppenderBase.getAppender("list")); + assertNull(asyncAppenderBase.getAppender("second")); + Iterator> it = asyncAppenderBase.iteratorForAppenders(); + assertSame(listAppender, it.next()); + assertFalse(it.hasNext()); + } + + @Test + public void detachAppenderByReferenceRemovesItWithoutStoppingIt() { + asyncAppenderBase.addAppender(listAppender); + + assertTrue(asyncAppenderBase.detachAppender(listAppender)); + + assertFalse(asyncAppenderBase.isAttached(listAppender)); + assertFalse(asyncAppenderBase.iteratorForAppenders().hasNext()); + assertTrue(listAppender.isStarted()); + assertFalse(asyncAppenderBase.detachAppender(listAppender)); + } + + @Test + public void detachAppenderByNameRemovesItWithoutStoppingIt() { + asyncAppenderBase.addAppender(listAppender); + + assertTrue(asyncAppenderBase.detachAppender("list")); + + assertNull(asyncAppenderBase.getAppender("list")); + assertTrue(listAppender.isStarted()); + assertFalse(asyncAppenderBase.detachAppender("list")); + } + + @Test + public void detachAndStopAllAppendersStopsTheAttachedAppender() { + asyncAppenderBase.addAppender(listAppender); + + asyncAppenderBase.detachAndStopAllAppenders(); + + assertFalse(listAppender.isStarted()); + assertFalse(asyncAppenderBase.iteratorForAppenders().hasNext()); + } + + @Test(timeout = 5000) + public void interruptedWorkerFlushesQueueAndExitsWhileAppenderIsStarted() throws InterruptedException { + BlockingListAppender la = new BlockingListAppender(0); + la.interruptWorkerOnRelease = true; + la.setContext(context); + la.setName("blocking"); + la.start(); + asyncAppenderBase.addAppender(la); + asyncAppenderBase.setQueueSize(10); + asyncAppenderBase.start(); + assertEquals(10, asyncAppenderBase.getRemainingCapacity()); + + asyncAppenderBase.doAppend(0); + la.entered.await(); + // the worker is now blocked inside append(0): these stay in the queue + asyncAppenderBase.doAppend(1); + asyncAppenderBase.doAppend(2); + assertEquals(8, asyncAppenderBase.getRemainingCapacity()); + + // on release, the worker interrupts itself, so its next take() throws + // InterruptedException although the async appender is still started + la.release.countDown(); + asyncAppenderBase.worker.join(); + + assertTrue(asyncAppenderBase.isStarted()); + assertEquals(Arrays.asList(0, 1, 2), la.list); + assertEquals(0, asyncAppenderBase.getNumberOfElementsInQueue()); + assertFalse("worker should stop the attached appender on exit", la.isStarted()); + assertFalse(asyncAppenderBase.worker.isInterrupted()); + statusChecker.assertContainsMatch("Worker thread will flush remaining events before exiting."); + + asyncAppenderBase.stop(); + assertFalse(asyncAppenderBase.isStarted()); + statusChecker.assertContainsMatch("Queue flush finished successfully within timeout."); + statusChecker.assertIsErrorFree(); + } + + @Test(timeout = 5000) + public void stopReportsErrorWhenInterruptedWhileWaitingForWorker() throws InterruptedException { + final BlockingListAppender la = new BlockingListAppender(0); + la.setContext(context); + la.setName("blocking"); + la.start(); + asyncAppenderBase.addAppender(la); + // 0 = wait for the worker without a time limit + asyncAppenderBase.setMaxFlushTime(0); + asyncAppenderBase.start(); + + asyncAppenderBase.doAppend(0); + la.entered.await(); + asyncAppenderBase.doAppend(1); + asyncAppenderBase.doAppend(2); + + Thread stopper = new Thread(new Runnable() { + @Override + public void run() { + asyncAppenderBase.stop(); + } + }, "AsyncAppenderBaseTest-stopper"); + stopper.start(); + // The worker ignores interrupts until released, so stop() can only return + // by being interrupted while it waits for the worker to finish. + while (stopper.isAlive()) { + stopper.interrupt(); + stopper.join(10); + } + + assertFalse(asyncAppenderBase.isStarted()); + statusChecker.assertContainsMatch(Status.ERROR, + "Failed to join worker thread. 2 queued events may be discarded."); + statusChecker.asssertContainsException(InterruptedException.class); + statusChecker.assertNoMatch("Queue flush finished successfully"); + + // let the worker finish so that it doesn't outlive this test + la.release.countDown(); + asyncAppenderBase.worker.join(); + assertEquals(Arrays.asList(0, 1, 2), la.list); + } + + /** + * A ListAppender that blocks the calling (worker) thread inside the append + * of {@link #blockingEvent} until {@link #release} opens, ignoring interrupts + * meanwhile. It restores a swallowed interrupt on release, and interrupts its + * thread on release anyway if {@link #interruptWorkerOnRelease} is set. + */ + static class BlockingListAppender extends ListAppender { + final CountDownLatch entered = new CountDownLatch(1); + final CountDownLatch release = new CountDownLatch(1); + final int blockingEvent; + volatile boolean interruptWorkerOnRelease; + + BlockingListAppender(int blockingEvent) { + this.blockingEvent = blockingEvent; + } + + @Override + protected void append(Integer e) { + super.append(e); + if (e != blockingEvent) { + return; + } + entered.countDown(); + boolean interrupted = false; + while (true) { + try { + release.await(); + break; + } catch (InterruptedException ie) { + interrupted = true; + } + } + if (interrupted || interruptWorkerOnRelease) { + Thread.currentThread().interrupt(); + } + } + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/BasicStatusManagerTest.java b/logback-android/src/test/java/ch/qos/logback/core/BasicStatusManagerTest.java index ddc836d6c..d75f971a3 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/BasicStatusManagerTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/BasicStatusManagerTest.java @@ -23,6 +23,7 @@ import static org.junit.Assert.assertTrue; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import org.junit.Before; @@ -34,6 +35,7 @@ import ch.qos.logback.core.status.OnConsoleStatusListener; import ch.qos.logback.core.status.Status; import ch.qos.logback.core.status.StatusListener; +import ch.qos.logback.core.status.TrivialStatusListener; public class BasicStatusManagerTest { @@ -136,4 +138,26 @@ public void addsConsoleStatusListenerOnlyIfAbsent() { assertEquals(1, statusList.size()); assertEquals(csl, statusList.get(0)); } + + @Test + public void consoleListenerIsAddedWhenOnlyListenersOfOtherTypesArePresent() { + StatusListener other = new TrivialStatusListener(); + assertTrue(bsm.add(other)); + + assertTrue(bsm.add(csl)); + + assertEquals(Arrays.asList(other, csl), bsm.getCopyOfStatusListenerList()); + } + + @Test + public void addUniquelyAddsListenerWhenRegisteredListenersAreOfUnrelatedTypes() { + StatusListener other = new TrivialStatusListener(); + bsm.add(other); + + assertTrue(bsm.addUniquely(csl, contextAware)); + + assertEquals(Arrays.asList(other, csl), bsm.getCopyOfStatusListenerList()); + // no "already registered" warning was issued + assertEquals(0, bsm.getCount()); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/ContextBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/ContextBaseTest.java index 66fd6574c..c42344fc3 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/ContextBaseTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/ContextBaseTest.java @@ -20,13 +20,22 @@ import static junit.framework.Assert.assertSame; import static junit.framework.Assert.assertTrue; import static junit.framework.Assert.fail; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import org.junit.Test; +import org.junit.function.ThrowingRunnable; +import org.mockito.MockedStatic; import java.util.ArrayList; import java.util.concurrent.ExecutorService; import ch.qos.logback.core.spi.LifeCycle; +import ch.qos.logback.core.status.StatusManager; public class ContextBaseTest { @@ -107,6 +116,116 @@ public void run() { assertTrue("executing thread should be a daemon thread.", executingThreads.get(0).isDaemon()); } + @Test + public void setStatusManagerRejectsNull() { + StatusManager original = context.getStatusManager(); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, new ThrowingRunnable() { + @Override + public void run() { + context.setStatusManager(null); + } + }); + + assertEquals("null StatusManager not allowed", e.getMessage()); + assertSame(original, context.getStatusManager()); + } + + @Test + public void setStatusManagerReplacesStatusManager() { + StatusManager replacement = new BasicStatusManager(); + context.setStatusManager(replacement); + assertSame(replacement, context.getStatusManager()); + } + + @Test + public void nullNameIsAcceptedByUnnamedContext() { + context.setName(null); + assertNull(context.getName()); + context.setName("hello"); + assertEquals("hello", context.getName()); + } + + @Test + public void nullNameCannotReplaceExistingName() { + context.setName("hello"); + + assertThrows(IllegalStateException.class, new ThrowingRunnable() { + @Override + public void run() { + context.setName(null); + } + }); + + assertEquals("hello", context.getName()); + } + + @Test + public void isStartedFollowsStartAndStop() { + assertFalse(context.isStarted()); + context.start(); + assertTrue(context.isStarted()); + context.stop(); + assertFalse(context.isStarted()); + } + + @SuppressWarnings("deprecation") + @Test + public void getExecutorServiceReturnsTheScheduledExecutorService() { + try { + ExecutorService executorService = context.getExecutorService(); + assertSame(context.getScheduledExecutorService(), executorService); + } finally { + context.stop(); + } + } + + @Test + public void resetRemovesRegisteredShutdownHook() { + Thread hook = new Thread(); + Runtime.getRuntime().addShutdownHook(hook); + boolean hookStillRegistered = true; + try { + context.putObject(CoreConstants.SHUTDOWN_HOOK_THREAD, hook); + + context.reset(); + + assertNull(context.getObject(CoreConstants.SHUTDOWN_HOOK_THREAD)); + // false: reset() already unregistered it + hookStillRegistered = Runtime.getRuntime().removeShutdownHook(hook); + assertFalse(hookStillRegistered); + } finally { + if (hookStillRegistered) { + Runtime.getRuntime().removeShutdownHook(hook); + } + } + } + + @Test + public void resetToleratesShutdownInProgress() { + Thread hook = new Thread(); + Runtime runtime = mock(Runtime.class); + doThrow(new IllegalStateException("Shutdown in progress")).when(runtime).removeShutdownHook(hook); + context.putObject(CoreConstants.SHUTDOWN_HOOK_THREAD, hook); + context.putProperty("keyA", "valA"); + + try (MockedStatic runtimeClass = mockStatic(Runtime.class)) { + runtimeClass.when(new MockedStatic.Verification() { + @Override + public void apply() { + Runtime.getRuntime(); + } + }).thenReturn(runtime); + + context.reset(); + } + + verify(runtime).removeShutdownHook(hook); + assertNull(context.getObject(CoreConstants.SHUTDOWN_HOOK_THREAD)); + assertNull(context.getProperty("keyA")); + assertTrue(lifeCycleManager.isReset()); + } + private static class InstrumentedContextBase extends ContextBase { private final LifeCycleManager lifeCycleManager; diff --git a/logback-android/src/test/java/ch/qos/logback/core/CoreConstantsTest.java b/logback-android/src/test/java/ch/qos/logback/core/CoreConstantsTest.java new file mode 100644 index 000000000..cd7215708 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/CoreConstantsTest.java @@ -0,0 +1,126 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.Modifier; + +import org.junit.Test; + +public class CoreConstantsTest { + + private static final String JAVA_VERSION = "java.version"; + + @Test + public void hasPublicNoArgConstructor() throws Exception { + Constructor constructor = CoreConstants.class.getConstructor(); + + assertTrue(Modifier.isPublic(constructor.getModifiers())); + assertSame(CoreConstants.class, constructor.newInstance().getClass()); + } + + @Test + public void corePoolSizeIsOneWhenJavaVersionIsFiveOrHigher() throws Exception { + assertEquals(1, corePoolSizeForJavaVersion("1.8.0_402")); + assertEquals(1, corePoolSizeForJavaVersion("17.0.2")); + } + + @Test + public void corePoolSizeIsZeroWhenJavaVersionIsBelowFive() throws Exception { + // Android's runtime reports a java.version of "0" + assertEquals(0, corePoolSizeForJavaVersion("0")); + } + + /** + * Initializes a fresh copy of {@link CoreConstants} (in its own class loader) + * while the {@code java.version} system property has the given value, and + * returns that copy's {@link CoreConstants#CORE_POOL_SIZE}. + */ + private static int corePoolSizeForJavaVersion(String javaVersion) throws Exception { + String original = System.getProperty(JAVA_VERSION); + System.setProperty(JAVA_VERSION, javaVersion); + try { + ClassLoader loader = new CoreConstantsReloadingClassLoader(CoreConstants.class.getClassLoader()); + Class reloaded = Class.forName(CoreConstants.class.getName(), true, loader); + assertNotSame(CoreConstants.class, reloaded); + return reloaded.getField("CORE_POOL_SIZE").getInt(null); + } finally { + System.setProperty(JAVA_VERSION, original); + } + } + + /** + * Defines its own copy of {@link CoreConstants} from the class file of the + * parent's copy, and delegates every other class to the parent. + */ + static class CoreConstantsReloadingClassLoader extends ClassLoader { + + CoreConstantsReloadingClassLoader(ClassLoader parent) { + super(parent); + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!CoreConstants.class.getName().equals(name)) { + return super.loadClass(name, resolve); + } + synchronized (this) { + Class c = findLoadedClass(name); + if (c == null) { + byte[] bytes = readClassFile(name); + c = defineClass(name, bytes, 0, bytes.length); + } + if (resolve) { + resolveClass(c); + } + return c; + } + } + + private byte[] readClassFile(String name) throws ClassNotFoundException { + String resource = name.replace('.', '/') + ".class"; + InputStream in = getParent().getResourceAsStream(resource); + if (in == null) { + throw new ClassNotFoundException(name); + } + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int n; + while ((n = in.read(buffer)) != -1) { + out.write(buffer, 0, n); + } + return out.toByteArray(); + } catch (IOException e) { + throw new ClassNotFoundException(name, e); + } finally { + try { + in.close(); + } catch (IOException ignored) { + // nothing more to do + } + } + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/LayoutBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/LayoutBaseTest.java new file mode 100644 index 000000000..02bbd2aa3 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/LayoutBaseTest.java @@ -0,0 +1,72 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class LayoutBaseTest { + + private final EchoLayoutBase layout = new EchoLayoutBase(); + + @Test + public void contentTypeIsPlainText() { + assertEquals("text/plain", layout.getContentType()); + } + + @Test + public void headersAndFootersAreNullUntilSet() { + assertNull(layout.getFileHeader()); + assertNull(layout.getPresentationHeader()); + assertNull(layout.getPresentationFooter()); + assertNull(layout.getFileFooter()); + + layout.setFileHeader("fh"); + layout.setPresentationHeader("ph"); + layout.setPresentationFooter("pf"); + layout.setFileFooter("ff"); + + assertEquals("fh", layout.getFileHeader()); + assertEquals("ph", layout.getPresentationHeader()); + assertEquals("pf", layout.getPresentationFooter()); + assertEquals("ff", layout.getFileFooter()); + } + + @Test + public void keepsContextAndFollowsLifecycle() { + Context context = new ContextBase(); + layout.setContext(context); + assertSame(context, layout.getContext()); + + assertFalse(layout.isStarted()); + layout.start(); + assertTrue(layout.isStarted()); + layout.stop(); + assertFalse(layout.isStarted()); + } + + static class EchoLayoutBase extends LayoutBase { + @Override + public String doLayout(Object event) { + return String.valueOf(event); + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/LogbackExceptionTest.java b/logback-android/src/test/java/ch/qos/logback/core/LogbackExceptionTest.java new file mode 100644 index 000000000..3d2f8aa94 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/LogbackExceptionTest.java @@ -0,0 +1,43 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +import org.junit.Test; + +public class LogbackExceptionTest { + + @Test + public void messageConstructorKeepsMessageWithoutCause() { + LogbackException e = new LogbackException("failed"); + + assertEquals("failed", e.getMessage()); + assertNull(e.getCause()); + } + + @Test + public void messageAndCauseConstructorKeepsBoth() { + Throwable cause = new IllegalStateException("root"); + + LogbackException e = new LogbackException("failed", cause); + + assertEquals("failed", e.getMessage()); + assertSame(cause, e.getCause()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/NOPOutputStreamTest.java b/logback-android/src/test/java/ch/qos/logback/core/NOPOutputStreamTest.java new file mode 100644 index 000000000..1c28ef4a7 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/NOPOutputStreamTest.java @@ -0,0 +1,36 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core; + +import org.junit.Test; + +public class NOPOutputStreamTest { + + @Test + public void discardsEveryWriteWithoutFailing() throws Exception { + NOPOutputStream out = new NOPOutputStream(); + + out.write(0); + out.write(255); + out.write(-1); + out.write(new byte[] {1, 2, 3}); + out.write(new byte[] {1, 2, 3}, 1, 2); + out.flush(); + out.close(); + // still usable after close: it holds no resource + out.write('x'); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/OutputStreamAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/core/OutputStreamAppenderTest.java index 9d752802f..8673b9fb9 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/OutputStreamAppenderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/OutputStreamAppenderTest.java @@ -18,9 +18,13 @@ import static junit.framework.Assert.assertTrue; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; @@ -33,10 +37,13 @@ import ch.qos.logback.core.encoder.EncoderBase; import ch.qos.logback.core.encoder.LayoutWrappingEncoder; import ch.qos.logback.core.pattern.parser.SamplePatternLayout; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; public class OutputStreamAppenderTest { Context context = new ContextBase(); + StatusChecker statusChecker = new StatusChecker(context); @Before public void setUp() throws Exception { @@ -152,6 +159,237 @@ protected void writeOut(Object event) throws IOException { assertEquals("a\nb\n", utf8(baos)); } + @Test + public void startWithoutEncoderReportsErrorAndDoesNotStart() { + OutputStreamAppender appender = newAppender(null); + appender.setOutputStream(new ByteArrayOutputStream()); + statusChecker.assertContainsMatch(Status.WARN, "Encoder has not been set. Cannot invoke its init method."); + + appender.start(); + + assertFalse(appender.isStarted()); + statusChecker.assertContainsMatch(Status.ERROR, "No encoder set for the appender named \"osa\"."); + statusChecker.assertNoMatch("No output stream set"); + } + + @Test + public void startWithoutOutputStreamReportsErrorAndDoesNotStart() { + OutputStreamAppender appender = newAppender(new Utf8LineEncoder()); + + appender.start(); + + assertFalse(appender.isStarted()); + statusChecker.assertContainsMatch(Status.ERROR, "No output stream set for the appender named \"osa\"."); + statusChecker.assertNoMatch("No encoder set"); + } + + @Test + public void startWithoutEncoderAndOutputStreamReportsBothErrors() { + OutputStreamAppender appender = newAppender(null); + + appender.start(); + + assertFalse(appender.isStarted()); + assertEquals(2, statusChecker.matchCount("No (encoder|output stream) set for the appender named \"osa\".")); + } + + @Test + public void appendOnNonStartedAppenderWritesNothing() { + OutputStreamAppender appender = newAppender(new Utf8LineEncoder()); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + appender.setOutputStream(baos); + + appender.append("ignored"); + + assertEquals("", utf8(baos)); + } + + @Test + public void subAppendOnNonStartedAppenderWritesNothing() { + OutputStreamAppender appender = newAppender(new Utf8LineEncoder()); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + appender.setOutputStream(baos); + + appender.subAppend("ignored"); + + assertEquals("", utf8(baos)); + } + + @Test + public void everyEventIsFlushedByDefault() { + OutputStreamAppender appender = newAppender(new Utf8LineEncoder()); + FaultInjectingOutputStream out = new FaultInjectingOutputStream(); + appender.setOutputStream(out); + appender.start(); + assertTrue(appender.isImmediateFlush()); + + appender.doAppend("a"); + appender.doAppend("b"); + + assertEquals("a\nb\n", out.text()); + assertEquals(2, out.flushCount); + } + + @Test + public void eventsAreNotFlushedWhenImmediateFlushIsOff() { + OutputStreamAppender appender = newAppender(new Utf8LineEncoder()); + FaultInjectingOutputStream out = new FaultInjectingOutputStream(); + appender.setOutputStream(out); + appender.setImmediateFlush(false); + appender.start(); + + appender.doAppend("a"); + + assertFalse(appender.isImmediateFlush()); + assertEquals("a\n", out.text()); + assertEquals(0, out.flushCount); + } + + @Test + public void writeFailureStopsAppenderAndReportsError() { + OutputStreamAppender appender = newAppender(new Utf8LineEncoder()); + FaultInjectingOutputStream out = new FaultInjectingOutputStream(); + appender.setOutputStream(out); + appender.start(); + out.failWrites = true; + + appender.doAppend("lost"); + + assertFalse(appender.isStarted()); + statusChecker.assertContainsMatch(Status.ERROR, "IO failure in appender"); + statusChecker.asssertContainsException(IOException.class); + } + + @Test + public void headerWriteFailureStopsAppenderAndReportsError() { + Utf8LineEncoder encoder = new Utf8LineEncoder(); + encoder.header = Utf8LineEncoder.toUtf8("header\n"); + OutputStreamAppender appender = newAppender(encoder); + appender.setOutputStream(new ByteArrayOutputStream()); + appender.start(); + assertTrue(appender.isStarted()); + + FaultInjectingOutputStream second = new FaultInjectingOutputStream(); + second.failWrites = true; + appender.setOutputStream(second); + + assertFalse(appender.isStarted()); + assertSame(second, appender.getOutputStream()); + statusChecker.assertContainsMatch(Status.ERROR, "Failed to initialize encoder for appender named \\[osa\\]."); + statusChecker.asssertContainsException(IOException.class); + } + + @Test + public void footerWriteFailureStopsAppenderAndReportsError() { + Utf8LineEncoder encoder = new Utf8LineEncoder(); + encoder.footer = Utf8LineEncoder.toUtf8("footer\n"); + OutputStreamAppender appender = newAppender(encoder); + FaultInjectingOutputStream first = new FaultInjectingOutputStream(); + appender.setOutputStream(first); + appender.start(); + first.failWrites = true; + + // replacing the stream closes the previous one, writing the footer first + ByteArrayOutputStream second = new ByteArrayOutputStream(); + appender.setOutputStream(second); + + assertFalse(appender.isStarted()); + assertTrue(first.closed); + assertSame(second, appender.getOutputStream()); + statusChecker.assertContainsMatch(Status.ERROR, "Failed to write footer for appender named \\[osa\\]."); + } + + @Test + public void closeFailureIsReportedOnStop() { + OutputStreamAppender appender = newAppender(new Utf8LineEncoder()); + FaultInjectingOutputStream out = new FaultInjectingOutputStream(); + out.failClose = true; + appender.setOutputStream(out); + appender.start(); + + appender.stop(); + + assertFalse(appender.isStarted()); + statusChecker.assertContainsMatch(Status.ERROR, "Could not close output stream for OutputStreamAppender."); + statusChecker.asssertContainsException(IOException.class); + } + + @Test + public void stopWithoutEncoderClosesStreamWithoutFooter() { + OutputStreamAppender appender = newAppender(null); + FaultInjectingOutputStream out = new FaultInjectingOutputStream(); + appender.setOutputStream(out); + + appender.stop(); + + assertTrue(out.closed); + assertEquals("", out.text()); + assertNull(appender.getOutputStream()); + statusChecker.assertIsErrorFree(); + } + + @Test + public void settingNullOutputStreamClosesPreviousStreamAndWritesNoHeader() { + Utf8LineEncoder encoder = new Utf8LineEncoder(); + encoder.header = Utf8LineEncoder.toUtf8("header\n"); + encoder.footer = Utf8LineEncoder.toUtf8("footer\n"); + OutputStreamAppender appender = newAppender(encoder); + FaultInjectingOutputStream out = new FaultInjectingOutputStream(); + appender.setOutputStream(out); + + appender.setOutputStream(null); + + assertNull(appender.getOutputStream()); + assertTrue(out.closed); + assertEquals("header\nfooter\n", out.text()); + statusChecker.assertIsErrorFree(); + } + + @Test + public void emptyHeaderWritesNothing() { + Utf8LineEncoder encoder = new Utf8LineEncoder(); + encoder.header = new byte[0]; + OutputStreamAppender appender = newAppender(encoder); + FaultInjectingOutputStream out = new FaultInjectingOutputStream(); + + appender.setOutputStream(out); + + assertEquals("", out.text()); + assertEquals(0, out.flushCount); + } + + @Test + public void encoderInitWithoutEncoderWritesNothing() { + OutputStreamAppender appender = newAppender(null); + FaultInjectingOutputStream out = new FaultInjectingOutputStream(); + appender.setOutputStream(out); + + appender.encoderInit(); + + assertEquals("", out.text()); + statusChecker.assertIsErrorFree(); + } + + @Test + public void encoderCloseWithoutOutputStreamDoesNothing() { + Utf8LineEncoder encoder = new Utf8LineEncoder(); + encoder.footer = Utf8LineEncoder.toUtf8("footer\n"); + OutputStreamAppender appender = newAppender(encoder); + + appender.encoderClose(); + + assertNull(appender.getOutputStream()); + statusChecker.assertIsErrorFree(); + } + + private OutputStreamAppender newAppender(Utf8LineEncoder encoder) { + OutputStreamAppender appender = new OutputStreamAppender(); + appender.setContext(context); + appender.setName("osa"); + appender.setEncoder(encoder); + return appender; + } + static String utf8(ByteArrayOutputStream baos) { return new String(baos.toByteArray(), StandardCharsets.UTF_8); } @@ -182,4 +420,49 @@ static byte[] toUtf8(String s) { return s.getBytes(StandardCharsets.UTF_8); } } + + /** + * Records what is written and flushed, and throws IOException from + * write()/close() on demand. + */ + static class FaultInjectingOutputStream extends OutputStream { + final ByteArrayOutputStream written = new ByteArrayOutputStream(); + boolean failWrites; + boolean failClose; + boolean closed; + int flushCount; + + @Override + public void write(int b) throws IOException { + if (failWrites) { + throw new IOException("write failed"); + } + written.write(b); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + if (failWrites) { + throw new IOException("write failed"); + } + written.write(b, off, len); + } + + @Override + public void flush() { + flushCount++; + } + + @Override + public void close() throws IOException { + if (failClose) { + throw new IOException("close failed"); + } + closed = true; + } + + String text() { + return utf8(written); + } + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/PropertyDefinerBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/PropertyDefinerBaseTest.java new file mode 100644 index 000000000..ee27e0aee --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/PropertyDefinerBaseTest.java @@ -0,0 +1,33 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class PropertyDefinerBaseTest { + + @Test + public void booleanAsStrReturnsTrueForTrue() { + assertEquals("true", PropertyDefinerBase.booleanAsStr(true)); + } + + @Test + public void booleanAsStrReturnsFalseForFalse() { + assertEquals("false", PropertyDefinerBase.booleanAsStr(false)); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/UnsynchronizedAppenderBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/UnsynchronizedAppenderBaseTest.java new file mode 100644 index 000000000..eda263375 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/UnsynchronizedAppenderBaseTest.java @@ -0,0 +1,189 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.filter.Filter; +import ch.qos.logback.core.spi.FilterReply; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; + +public class UnsynchronizedAppenderBaseTest { + + private final Context context = new ContextBase(); + private final StatusChecker statusChecker = new StatusChecker(context); + private final RecordingAppender appender = new RecordingAppender(); + + @Before + public void setUp() { + appender.setContext(context); + appender.setName("recording"); + } + + @Test + public void doAppendIgnoresEventsLoggedFromWithinAppend() { + appender.start(); + + appender.doAppend("outer"); + + // append() re-entered doAppend("inner") on the same thread but the guard dropped it + assertEquals(Collections.singletonList("outer"), appender.events); + assertEquals(1, appender.reentrantCalls); + statusChecker.assertIsErrorFree(); + } + + @Test + public void guardIsReleasedAfterEachAppend() { + appender.start(); + + appender.doAppend("a"); + appender.doAppend("b"); + + assertEquals(Arrays.asList("a", "b"), appender.events); + } + + @Test + public void warningsAboutNonStartedAppenderAreCappedAtAllowedRepeats() { + for (int i = 0; i < UnsynchronizedAppenderBase.ALLOWED_REPEATS + 2; i++) { + appender.doAppend("event" + i); + } + + assertTrue(appender.events.isEmpty()); + assertEquals(UnsynchronizedAppenderBase.ALLOWED_REPEATS, + statusChecker.matchCount("Attempted to append to non started appender \\[recording\\].")); + assertEquals(Status.WARN, statusChecker.getHighestLevel(0)); + } + + @Test + public void eventDeniedByFilterIsNotAppended() { + appender.start(); + appender.addFilter(new FixedReplyFilter(FilterReply.DENY)); + + appender.doAppend("denied"); + + assertTrue(appender.events.isEmpty()); + statusChecker.assertIsErrorFree(); + } + + @Test + public void eventAcceptedByFilterIsAppended() { + appender.start(); + appender.addFilter(new FixedReplyFilter(FilterReply.ACCEPT)); + + appender.doAppend("accepted"); + + assertEquals(Collections.singletonList("accepted"), appender.events); + } + + @Test + public void exceptionThrownByAppendIsReportedAsErrorUpToAllowedRepeats() { + appender.start(); + appender.failure = new IllegalStateException("boom"); + + for (int i = 0; i < UnsynchronizedAppenderBase.ALLOWED_REPEATS + 2; i++) { + appender.doAppend("event" + i); + } + + assertEquals(UnsynchronizedAppenderBase.ALLOWED_REPEATS, + statusChecker.matchCount("Appender \\[recording\\] failed to append.")); + assertEquals(Status.ERROR, statusChecker.getHighestLevel(0)); + statusChecker.asssertContainsException(IllegalStateException.class); + } + + @Test + public void guardIsReleasedAfterAppendThrows() { + appender.start(); + appender.failure = new IllegalStateException("boom"); + appender.doAppend("fails"); + + appender.failure = null; + appender.doAppend("succeeds"); + + assertEquals(Arrays.asList("fails", "succeeds"), appender.events); + } + + @Test + public void clearAllFiltersRemovesDenyingFilter() { + appender.start(); + appender.addFilter(new FixedReplyFilter(FilterReply.DENY)); + appender.doAppend("denied"); + + appender.clearAllFilters(); + appender.doAppend("accepted"); + + assertEquals(Collections.singletonList("accepted"), appender.events); + assertTrue(appender.getCopyOfAttachedFiltersList().isEmpty()); + } + + @Test + public void getCopyOfAttachedFiltersListReturnsDetachedCopyOfFilters() { + Filter filter = new FixedReplyFilter(FilterReply.NEUTRAL); + appender.addFilter(filter); + + List> copy = appender.getCopyOfAttachedFiltersList(); + assertEquals(1, copy.size()); + assertSame(filter, copy.get(0)); + + copy.clear(); + assertEquals(1, appender.getCopyOfAttachedFiltersList().size()); + } + + /** + * Records appended events, optionally throws, and, when it sees the "outer" + * event, logs a nested event through its own {@link #doAppend(Object)}. + */ + static class RecordingAppender extends UnsynchronizedAppenderBase { + final List events = new ArrayList(); + int reentrantCalls = 0; + RuntimeException failure; + + @Override + protected void append(String event) { + events.add(event); + if (failure != null) { + throw failure; + } + if ("outer".equals(event)) { + reentrantCalls++; + doAppend("inner"); + } + } + } + + static class FixedReplyFilter extends Filter { + private final FilterReply reply; + + FixedReplyFilter(FilterReply reply) { + this.reply = reply; + } + + @Override + public FilterReply decide(String event) { + return reply; + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/appender/ConsoleAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/core/appender/ConsoleAppenderTest.java index 95da4c7e8..8db8ee155 100755 --- a/logback-android/src/test/java/ch/qos/logback/core/appender/ConsoleAppenderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/appender/ConsoleAppenderTest.java @@ -161,4 +161,44 @@ public void wrongTarget() { } + @Test + public void targetDefaultsToSystemOut() { + ConsoleAppender ca = (ConsoleAppender) getAppender(); + assertEquals("System.out", ca.getTarget()); + } + + @Test + public void systemErrTargetWritesToSystemErr() { + PrintStream originalErr = System.err; + XTeeOutputStream errTee = new XTeeOutputStream(null); + System.setErr(new PrintStream(errTee)); + try { + ConsoleAppender ca = (ConsoleAppender) getAppender(); + ca.setContext(context); + // surrounding spaces are ignored + ca.setTarget(" System.err "); + assertEquals("System.err", ca.getTarget()); + ca.setEncoder(new DummyEncoder()); + ca.start(); + ca.doAppend(new Object()); + + assertEquals(DummyLayout.DUMMY, errTee.toString()); + assertEquals("", tee.toString()); + new StatusChecker(context).assertIsWarningOrErrorFree(); + } finally { + System.setErr(originalErr); + } + } + + @Test + public void invalidTargetKeepsPreviouslySetTarget() { + ConsoleAppender ca = (ConsoleAppender) getAppender(); + ca.setContext(context); + ca.setTarget("System.err"); + + ca.setTarget("foo"); + + assertEquals("System.err", ca.getTarget()); + new StatusChecker(context).assertContainsMatch(Status.WARN, "\\[foo\\] should be one of"); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/appender/FileAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/core/appender/FileAppenderTest.java index 62724bf40..d453d1232 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/appender/FileAppenderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/appender/FileAppenderTest.java @@ -19,17 +19,25 @@ import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.assertTrue; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; import java.io.IOException; +import java.io.OutputStream; import java.io.RandomAccessFile; +import java.nio.channels.ClosedChannelException; import java.nio.channels.FileLock; import java.nio.channels.OverlappingFileLockException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.regex.Pattern; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.CoreConstants; import ch.qos.logback.core.encoder.EncoderBase; import ch.qos.logback.core.recovery.ResilientFileOutputStream; import ch.qos.logback.core.status.StatusChecker; @@ -47,6 +55,7 @@ import ch.qos.logback.core.status.StatusManager; import ch.qos.logback.core.testUtil.RandomUtil; import ch.qos.logback.core.util.CoreTestConstants; +import ch.qos.logback.core.util.FileSize; import ch.qos.logback.core.util.StatusPrinter; public class FileAppenderTest extends AbstractAppenderTest { @@ -260,6 +269,296 @@ public void nonPrudentModeDoesNotLockFile() throws Exception { assertEquals("hello\n", readUtf8(file)); } + @Test + public void startWithoutFileReportsErrorAndDoesNotStart() { + FileAppender fa = new FileAppender(); + fa.setContext(context); + fa.setName("nofile"); + fa.setEncoder(new Utf8LineEncoder()); + + fa.start(); + + assertFalse(fa.isStarted()); + assertNull(fa.getOutputStream()); + new StatusChecker(context).assertContainsMatch(Status.ERROR, + "\"File\" property not set for appender named \\[nofile\\]"); + } + + @Test + public void startReportsErrorWhenFileCannotBeOpened() { + // a directory cannot be opened as a log file + File dir = tmp.getRoot(); + FileAppender fa = newFileAppender("dir", dir, new Utf8LineEncoder()); + + fa.start(); + + assertFalse(fa.isStarted()); + StatusChecker checker = new StatusChecker(context); + checker.assertContainsMatch(Status.ERROR, + Pattern.quote("openFile(" + dir.getAbsolutePath() + ",true) failed")); + checker.asssertContainsException(FileNotFoundException.class); + } + + @Test + public void startReportsErrorWhenParentDirectoryCannotBeCreated() throws IOException { + // the parent directory would have to be created under a regular file + File blocker = tmp.newFile("blocker"); + File file = new File(new File(blocker, "sub"), "app.log"); + FileAppender fa = newFileAppender("noparent", file, new Utf8LineEncoder()); + + fa.start(); + + assertFalse(fa.isStarted()); + StatusChecker checker = new StatusChecker(context); + checker.assertContainsMatch(Status.ERROR, + Pattern.quote("Failed to create parent directories for [" + file.getAbsolutePath() + "]")); + checker.assertContainsMatch(Status.ERROR, + Pattern.quote("openFile(" + file.getAbsolutePath() + ",true) failed")); + } + + @Test + public void appenderWorksWithoutFilenameCollisionMap() throws IOException { + ContextBase contextWithoutMap = new ContextBase(); + contextWithoutMap.removeObject(CoreConstants.FA_FILENAME_COLLISION_MAP); + File file = new File(tmp.getRoot(), "nomap.log"); + FileAppender fa = new FileAppender(); + fa.setContext(contextWithoutMap); + fa.setName("nomap"); + fa.setEncoder(new Utf8LineEncoder()); + fa.setFile(file.getAbsolutePath()); + + fa.start(); + fa.doAppend("hello"); + fa.stop(); + + assertFalse(fa.isStarted()); + assertEquals("hello\n", readUtf8(file)); + assertNull(contextWithoutMap.getObject(CoreConstants.FA_FILENAME_COLLISION_MAP)); + new StatusChecker(contextWithoutMap).assertIsErrorFree(); + } + + @Test + public void stoppedAppenderReleasesItsFileForLaterAppenders() { + File file = new File(tmp.getRoot(), "released.log"); + FileAppender first = newFileAppender("first", file, new Utf8LineEncoder()); + first.start(); + first.stop(); + + FileAppender second = newFileAppender("second", file, new Utf8LineEncoder()); + second.start(); + + assertTrue(second.isStarted()); + second.stop(); + new StatusChecker(context).assertIsErrorFree(); + } + + @Test + public void unnamedAppenderCanBeStartedAndStopped() throws IOException { + File file = new File(tmp.getRoot(), "unnamed.log"); + FileAppender fa = newFileAppender(null, file, new Utf8LineEncoder()); + + fa.start(); + fa.doAppend("hello"); + fa.stop(); + + assertFalse(fa.isStarted()); + assertEquals("hello\n", readUtf8(file)); + new StatusChecker(context).assertIsErrorFree(); + } + + @Test + public void lazyAndSyncOnFlushAreOffByDefault() { + FileAppender fa = new FileAppender(); + assertFalse(fa.getLazy()); + assertFalse(fa.isSyncOnFlush()); + + fa.setLazy(true); + fa.setSyncOnFlush(true); + + assertTrue(fa.getLazy()); + assertTrue(fa.isSyncOnFlush()); + } + + @Test + public void eventsStayInDefaultBufferWithoutImmediateFlush() throws IOException { + File file = new File(tmp.getRoot(), "buffered.log"); + FileAppender fa = newFileAppender("buffered", file, new Utf8LineEncoder()); + fa.setImmediateFlush(false); + fa.start(); + + fa.doAppend("hello"); + + assertEquals("", readUtf8(file)); + fa.stop(); + assertEquals("hello\n", readUtf8(file)); + } + + @Test + public void eventsLargerThanBufferSizeReachFileWithoutFlush() throws IOException { + File file = new File(tmp.getRoot(), "small-buffer.log"); + FileAppender fa = newFileAppender("small-buffer", file, new Utf8LineEncoder()); + fa.setImmediateFlush(false); + fa.setBufferSize(new FileSize(4)); + fa.start(); + + fa.doAppend("hello"); + + assertEquals("hello\n", readUtf8(file)); + new StatusChecker(context).assertContainsMatch(Status.INFO, "Setting bufferSize to \\[4 Bytes\\]"); + fa.stop(); + } + + @Test + public void prudentWriteSeeksPastDataAppendedByAnotherWriter() throws IOException { + File file = new File(tmp.getRoot(), "shared.log"); + FileAppender fa = newFileAppender("shared", file, new Utf8LineEncoder()); + // not in append mode, so the stream's position does not follow the end of file + fa.setAppend(false); + fa.start(); + fa.doAppend("a"); + appendUtf8(file, "other\n"); + fa.setPrudent(true); + + fa.doAppend("b"); + fa.stop(); + + assertEquals("a\nother\nb\n", readUtf8(file)); + new StatusChecker(context).assertIsErrorFree(); + } + + @Test + public void prudentWriteRestoresInterruptFlag() throws IOException { + File file = new File(tmp.getRoot(), "interrupted.log"); + FileAppender fa = newFileAppender("interrupted", file, new Utf8LineEncoder()); + fa.setPrudent(true); + fa.start(); + + boolean interruptedAfterAppend; + Thread.currentThread().interrupt(); + try { + fa.doAppend("hello"); + } finally { + // also clears the flag for later tests + interruptedAfterAppend = Thread.interrupted(); + } + fa.stop(); + + assertTrue(interruptedAfterAppend); + // the interrupt was cleared while locking, so the write went through + assertEquals("hello\n", readUtf8(file)); + new StatusChecker(context).assertIsErrorFree(); + } + + @Test + public void prudentWriteToClosedChannelIsReportedAsIOFailure() throws IOException { + File file = new File(tmp.getRoot(), "closed.log"); + FileAppender fa = newFileAppender("closed", file, new Utf8LineEncoder()); + fa.setPrudent(true); + fa.start(); + ((ResilientFileOutputStream) fa.getOutputStream()).getChannel().close(); + + fa.doAppend("lost"); + + // the failure is left to the resilient stream to recover from + assertTrue(fa.isStarted()); + StatusChecker checker = new StatusChecker(context); + checker.assertContainsMatch(Status.ERROR, "IO failure while writing to file"); + checker.asssertContainsException(ClosedChannelException.class); + checker.assertNoMatch("failed to append"); + assertEquals("", readUtf8(file)); + fa.stop(); + } + + @Test + public void prudentWriteDoesNotReleaseLockInvalidatedDuringWrite() throws IOException { + File file = new File(tmp.getRoot(), "invalidated.log"); + ChannelClosingEncoder encoder = new ChannelClosingEncoder(); + FileAppender fa = newFileAppender("invalidated", file, encoder); + encoder.appender = fa; + fa.setPrudent(true); + fa.start(); + + fa.doAppend("lost"); + + // releasing the invalidated lock would have thrown and stopped the appender + assertTrue(fa.isStarted()); + StatusChecker checker = new StatusChecker(context); + checker.assertNoMatch("IO failure in appender"); + checker.assertContainsMatch(Status.ERROR, "IO failure while writing to file"); + fa.stop(); + } + + @Test + public void prudentWriteIsSkippedWhenStreamHasNoChannel() throws IOException { + File file = new File(tmp.getRoot(), "no-channel.log"); + FileAppender fa = newFileAppender("no-channel", file, new Utf8LineEncoder()); + fa.setPrudent(true); + fa.start(); + fa.setOutputStream(new ChannelLessResilientFileOutputStream(file)); + + fa.doAppend("dropped"); + fa.stop(); + + assertEquals("", readUtf8(file)); + new StatusChecker(context).assertIsErrorFree(); + } + + @Test + public void lazyAppenderOpensFileOnlyOnce() throws IOException { + File file = new File(tmp.getRoot(), "lazy-once.log"); + FileAppender fa = newFileAppender("lazy-once", file, new Utf8LineEncoder()); + fa.setLazy(true); + fa.start(); + + fa.doAppend("a"); + OutputStream opened = fa.getOutputStream(); + fa.doAppend("b"); + + // the file was not reopened for the second event + assertSame(opened, fa.getOutputStream()); + assertEquals("a\nb\n", readUtf8(file)); + fa.stop(); + } + + @Test + public void lazyAppenderReportsCollisionOnFirstAppend() throws IOException { + File file = new File(tmp.getRoot(), "lazy-collision.log"); + FileAppender first = newFileAppender("first", file, new Utf8LineEncoder()); + first.start(); + FileAppender lazy = newFileAppender("lazy", file, new Utf8LineEncoder()); + lazy.setLazy(true); + lazy.start(); + assertTrue(lazy.isStarted()); + + lazy.doAppend("from lazy"); + + StatusChecker checker = new StatusChecker(context); + checker.assertContainsMatch(Status.ERROR, + "Collisions detected with FileAppender/RollingAppender instances defined earlier. Aborting."); + checker.assertContainsMatch(Status.ERROR, "'File' option has the same value"); + assertTrue(lazy.getOutputStream() instanceof NOPOutputStream); + assertEquals("", readUtf8(file)); + first.stop(); + lazy.stop(); + } + + @Test + public void lazyAppenderStopsWhenFileCannotBeOpened() { + File dir = tmp.getRoot(); + FileAppender fa = newFileAppender("lazy-dir", dir, new Utf8LineEncoder()); + fa.setLazy(true); + fa.start(); + assertTrue(fa.isStarted()); + + fa.doAppend("lost"); + + assertFalse(fa.isStarted()); + StatusChecker checker = new StatusChecker(context); + checker.assertContainsMatch(Status.ERROR, + Pattern.quote("openFile(" + dir.getAbsolutePath() + ",true) failed")); + checker.asssertContainsException(FileNotFoundException.class); + } + private FileAppender newFileAppender(String name, File file, EncoderBase encoder) { FileAppender fa = new FileAppender(); fa.setContext(context); @@ -300,6 +599,45 @@ public byte[] footerBytes() { } } + static void appendUtf8(File file, String text) throws IOException { + FileOutputStream out = new FileOutputStream(file, true); + try { + out.write(text.getBytes(StandardCharsets.UTF_8)); + } finally { + out.close(); + } + } + + /** + * Closes the appender's file channel while encoding, i.e. after the prudent + * write locked the file, which invalidates that lock. + */ + static class ChannelClosingEncoder extends Utf8LineEncoder { + FileAppender appender; + + @Override + public byte[] encode(Object event) { + try { + ((ResilientFileOutputStream) appender.getOutputStream()).getChannel().close(); + } catch (IOException e) { + throw new IllegalStateException(e); + } + return super.encode(event); + } + } + + /** + * A ResilientFileOutputStream whose underlying stream is gone, so it has no + * file channel. + */ + static class ChannelLessResilientFileOutputStream extends ResilientFileOutputStream { + ChannelLessResilientFileOutputStream(File file) throws IOException { + super(file, true, FileAppender.DEFAULT_BUFFER_SIZE); + close(); + os = null; + } + } + /** * Records, for each encoded event, whether this JVM held a lock on the file * while the event was being encoded (i.e. written). From 994d8834294c37762bbaf99ff3487803624360c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:17:09 +0000 Subject: [PATCH 41/79] test: tighten core appender tests against surviving mutants - OutputStreamAppenderTest: check that append() on a stopped appender never reaches subAppend(), not only that nothing is written (the subAppend guard alone hid a removed append guard). - FileAppenderTest: a lazy appender must not re-run its collision check on later events (assert error-free); a prudent write must not leave an uninterrupted thread interrupted; add a lazy + prudent test that the file opened on the first event is locked while each event is written. - CoreConstantsTest: define the reloaded CoreConstants with the original's protection domain, so JaCoCo records it even without includeNoLocationClasses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../qos/logback/core/CoreConstantsTest.java | 6 ++-- .../core/OutputStreamAppenderTest.java | 20 +++++++++++-- .../core/appender/FileAppenderTest.java | 30 ++++++++++++++++++- 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/logback-android/src/test/java/ch/qos/logback/core/CoreConstantsTest.java b/logback-android/src/test/java/ch/qos/logback/core/CoreConstantsTest.java index cd7215708..6a7b172d5 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/CoreConstantsTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/CoreConstantsTest.java @@ -72,7 +72,9 @@ private static int corePoolSizeForJavaVersion(String javaVersion) throws Excepti /** * Defines its own copy of {@link CoreConstants} from the class file of the - * parent's copy, and delegates every other class to the parent. + * parent's copy, and delegates every other class to the parent. The copy + * gets the original's protection domain (and so its code-source location), + * so that coverage agents record it even when they skip classes without one. */ static class CoreConstantsReloadingClassLoader extends ClassLoader { @@ -89,7 +91,7 @@ protected Class loadClass(String name, boolean resolve) throws ClassNotFoundE Class c = findLoadedClass(name); if (c == null) { byte[] bytes = readClassFile(name); - c = defineClass(name, bytes, 0, bytes.length); + c = defineClass(name, bytes, 0, bytes.length, CoreConstants.class.getProtectionDomain()); } if (resolve) { resolveClass(c); diff --git a/logback-android/src/test/java/ch/qos/logback/core/OutputStreamAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/core/OutputStreamAppenderTest.java index 8673b9fb9..a378fb1f8 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/OutputStreamAppenderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/OutputStreamAppenderTest.java @@ -194,14 +194,30 @@ public void startWithoutEncoderAndOutputStreamReportsBothErrors() { } @Test - public void appendOnNonStartedAppenderWritesNothing() { - OutputStreamAppender appender = newAppender(new Utf8LineEncoder()); + public void appendOnNonStartedAppenderDoesNotReachSubAppend() { + final List subAppended = new ArrayList(); + OutputStreamAppender appender = new OutputStreamAppender() { + @Override + protected void subAppend(Object event) { + subAppended.add(event); + super.subAppend(event); + } + }; + appender.setContext(context); + appender.setEncoder(new Utf8LineEncoder()); ByteArrayOutputStream baos = new ByteArrayOutputStream(); appender.setOutputStream(baos); appender.append("ignored"); + assertTrue(subAppended.isEmpty()); assertEquals("", utf8(baos)); + + appender.start(); + appender.append("written"); + + assertEquals(Arrays.asList("written"), subAppended); + assertEquals("written\n", utf8(baos)); } @Test diff --git a/logback-android/src/test/java/ch/qos/logback/core/appender/FileAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/core/appender/FileAppenderTest.java index d453d1232..af19c42cf 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/appender/FileAppenderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/appender/FileAppenderTest.java @@ -32,6 +32,7 @@ import java.nio.channels.OverlappingFileLockException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.regex.Pattern; @@ -246,12 +247,37 @@ public void prudentModeHoldsFileLockWhileEventIsWritten() throws Exception { FileAppender fa = newFileAppender("prudent-lock", file, encoder); fa.setPrudent(true); fa.start(); + // start from a non-interrupted thread + Thread.interrupted(); fa.doAppend("hello"); + // reads and clears the flag, so that it cannot leak into later tests + boolean interruptedAfterAppend = Thread.interrupted(); fa.stop(); assertEquals(Collections.singletonList(Boolean.TRUE), encoder.lockHeldDuringEncode); assertEquals("hello\n", readUtf8(file)); + // the thread was not interrupted before the write, so it must not be after it + assertFalse(interruptedAfterAppend); + new StatusChecker(context).assertIsErrorFree(); + } + + @Test + public void lazyPrudentAppenderLocksFileItOpensOnFirstAppend() throws Exception { + File file = new File(tmp.getRoot(), "lazy-prudent.log"); + LockProbingEncoder encoder = new LockProbingEncoder(file); + FileAppender fa = newFileAppender("lazy-prudent", file, encoder); + fa.setPrudent(true); + fa.setLazy(true); + fa.start(); + assertFalse(file.exists()); + + fa.doAppend("a"); + fa.doAppend("b"); + fa.stop(); + + assertEquals(Arrays.asList(Boolean.TRUE, Boolean.TRUE), encoder.lockHeldDuringEncode); + assertEquals("a\nb\n", readUtf8(file)); new StatusChecker(context).assertIsErrorFree(); } @@ -514,9 +540,11 @@ public void lazyAppenderOpensFileOnlyOnce() throws IOException { OutputStream opened = fa.getOutputStream(); fa.doAppend("b"); - // the file was not reopened for the second event + // the file was neither reopened nor checked again for collisions (which + // would now collide with this appender's own entry) for the second event assertSame(opened, fa.getOutputStream()); assertEquals("a\nb\n", readUtf8(file)); + new StatusChecker(context).assertIsErrorFree(); fa.stop(); } From 0786f9a5d34d0bce3a1f03f7b5f8c93feb77f158 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:58:07 +0000 Subject: [PATCH 42/79] fix: report an unterminated quoted pattern option as a ScanException When the escape char inside a quoted option was the second-to-last char of a pattern (e.g. %x{'a\'), the escape consumed the last char and OptionTokenizer then read past the end of the pattern, throwing StringIndexOutOfBoundsException. PatternLayoutBase.start() only catches ScanException, so the malformed pattern escaped as an unchecked exception instead of being reported as a parse error status like every other unterminated option (%x{'a\'}, %x{'abc}, %x{abc). Throw the same ScanException as the end-of-pattern handling when the pointer has run past the end after processing a char. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../core/pattern/parser/OptionTokenizer.java | 4 ++++ .../core/pattern/parser/OptionTokenizerTest.java | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/logback-android/src/main/java/ch/qos/logback/core/pattern/parser/OptionTokenizer.java b/logback-android/src/main/java/ch/qos/logback/core/pattern/parser/OptionTokenizer.java index 2702e730a..8f275ff62 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/pattern/parser/OptionTokenizer.java +++ b/logback-android/src/main/java/ch/qos/logback/core/pattern/parser/OptionTokenizer.java @@ -116,6 +116,10 @@ void tokenize(char firstChar, List tokenList) throws ScanException { break; } + if (tokenStream.pointer >= patternLength) { + // an escape sequence consumed the last char, leaving the quoted option unterminated + throw new ScanException("Unexpected end of pattern string in OptionTokenizer"); + } c = pattern.charAt(tokenStream.pointer); tokenStream.pointer++; } diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/OptionTokenizerTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/OptionTokenizerTest.java index b8c1ca943..1c6456625 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/OptionTokenizerTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/OptionTokenizerTest.java @@ -15,15 +15,30 @@ */ package ch.qos.logback.core.pattern.parser; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + import org.junit.Test; +import ch.qos.logback.core.spi.ScanException; + public class OptionTokenizerTest { + static final String UNEXPECTED_END = "Unexpected end of pattern string in OptionTokenizer"; + @Test public void testEmpty() { } + @Test + public void escapeConsumingLastCharOfQuotedOptionIsReportedAsScanException() { + // the backslash escapes the closing quote, which is the last char of the pattern + ScanException e = assertThrows(ScanException.class, + () -> new TokenStream("%x{'a\\'").tokenize()); + assertEquals(UNEXPECTED_END, e.getMessage()); + } + // // @Test // public void testEmpty() throws ScanException { From a509ab2fac3e70c207865cabd5bd61ef30aea9c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:59:40 +0000 Subject: [PATCH 43/79] refactor: fold TokenStream's unreachable enum-switch defaults into cases Both switches on TokenizerState in TokenStream.tokenize() list every enum constant, so javac's switch map sends each state to its own case and the default branch can never run (not even through mocks or reflection: no ordinal maps to it). The per-char switch had an empty default; the end-of-pattern switch had an implicit one. Put each default label on the last case (RIGHT_PARENTHESIS_STATE for the per-char switch, FORMAT_MODIFIER_STATE/OPTION_STATE for the end-of-pattern one), so the switches have no unreachable branch. Behavior is unchanged for every state. The added tests pin the end-of-pattern error that the FORMAT_MODIFIER/OPTION case raises. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../core/pattern/parser/TokenStream.java | 4 ++-- .../core/pattern/parser/TokenStreamTest.java | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/logback-android/src/main/java/ch/qos/logback/core/pattern/parser/TokenStream.java b/logback-android/src/main/java/ch/qos/logback/core/pattern/parser/TokenStream.java index 7b95c9db5..0cdc2a031 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/pattern/parser/TokenStream.java +++ b/logback-android/src/main/java/ch/qos/logback/core/pattern/parser/TokenStream.java @@ -96,10 +96,9 @@ List tokenize() throws ScanException { handleKeywordState(c, tokenList, buf); break; case RIGHT_PARENTHESIS_STATE: + default: // every state has a case; default shares the last one's handleRightParenthesisState(c, tokenList, buf); break; - - default: } } @@ -117,6 +116,7 @@ List tokenize() throws ScanException { case FORMAT_MODIFIER_STATE: case OPTION_STATE: + default: // every state has a case; default shares the last one's throw new ScanException("Unexpected end of pattern string"); } diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/TokenStreamTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/TokenStreamTest.java index 0abeeef48..815b00f81 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/TokenStreamTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/TokenStreamTest.java @@ -17,6 +17,7 @@ import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.fail; +import static org.junit.Assert.assertThrows; import java.util.ArrayList; import java.util.List; @@ -420,4 +421,25 @@ public void compositedKeywordFollowedByOptions() throws ScanException { assertEquals(witness, tl); } } + + @Test + public void patternEndingInFormatModifierIsRejected() { + ScanException e = assertThrows(ScanException.class, + () -> new TokenStream("abc %-5").tokenize()); + assertEquals("Unexpected end of pattern string", e.getMessage()); + } + + @Test + public void patternEndingWithPercentIsRejected() { + ScanException e = assertThrows(ScanException.class, + () -> new TokenStream("abc %").tokenize()); + assertEquals("Unexpected end of pattern string", e.getMessage()); + } + + @Test + public void patternEndingInOpeningCurlyBraceIsRejected() { + ScanException e = assertThrows(ScanException.class, + () -> new TokenStream("%x{").tokenize()); + assertEquals("Unexpected end of pattern string", e.getMessage()); + } } \ No newline at end of file From a8c8aa27424b6fe763f0bc1e8509ac6e4344c316 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:03:54 +0000 Subject: [PATCH 44/79] test: cover the core pattern parser (Parser, Compiler, tokenizers, nodes) Bring every line and branch of ch.qos.logback.core.pattern.parser to full unit-test coverage without Robolectric: - Node, FormattingNode, SimpleKeywordNode, CompositeNode, Token: pin equals for each differing field (type, value, next, format info, options, child, subclass mismatch), the hashCode formulas and delegation, and every toString form, including the chained " -> " suffix, the per-type Token names and the Object.toString fallback. - Parser: the TokenStream constructor, a pattern with no tokens and an empty composite (IllegalStateException), options after a composite's closing parenthesis, and, via a fixed-token TokenStream, a non-keyword after '%' and a composite closed by something other than ')' (error statuses plus ScanException). - Compiler: unregistered and non-composite composite keywords, a keyword mapped to a missing class (the %PARSER_ERROR[...] output and the error statuses with their cause), and skipping of unknown node types. - TokenStream/OptionTokenizer: null pattern, unterminated options (quoted, unclosed '{'), escaped quotes and curly braces, trailing backslashes, optionEscape, escape at the end of the pattern, and an unknown option-tokenizer state. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../core/pattern/parser/CompilerTest.java | 80 +++++++++++ .../pattern/parser/CompositeNodeTest.java | 98 ++++++++++++++ .../pattern/parser/FormattingNodeTest.java | 80 +++++++++++ .../logback/core/pattern/parser/NodeTest.java | 127 ++++++++++++++++++ .../pattern/parser/OptionTokenizerTest.java | 65 +++++++++ .../core/pattern/parser/ParserTest.java | 72 ++++++++++ .../pattern/parser/SimpleKeywordNodeTest.java | 99 ++++++++++++++ .../core/pattern/parser/TokenStreamTest.java | 63 +++++++++ .../core/pattern/parser/TokenTest.java | 119 ++++++++++++++++ 9 files changed, 803 insertions(+) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/pattern/parser/CompositeNodeTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/pattern/parser/FormattingNodeTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/pattern/parser/NodeTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/pattern/parser/SimpleKeywordNodeTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/pattern/parser/TokenTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/CompilerTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/CompilerTest.java index fbd6a6ea5..3cb9054e0 100755 --- a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/CompilerTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/CompilerTest.java @@ -16,6 +16,8 @@ package ch.qos.logback.core.pattern.parser; import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertTrue; import java.util.HashMap; @@ -29,7 +31,10 @@ import ch.qos.logback.core.pattern.Converter; import ch.qos.logback.core.pattern.Converter123; import ch.qos.logback.core.pattern.ConverterHello; +import ch.qos.logback.core.status.Status; import ch.qos.logback.core.status.StatusChecker; +import ch.qos.logback.core.util.DynamicClassLoadingException; +import ch.qos.logback.core.util.IncompatibleClassException; import ch.qos.logback.core.util.StatusPrinter; public class CompilerTest { @@ -257,4 +262,79 @@ public void testWithNopEscape() throws Exception { } } + /** Returns the only status whose message is exactly {@code message}. */ + Status statusWithMessage(String message) { + Status found = null; + for (Status s : context.getStatusManager().getCopyOfStatusList()) { + if (message.equals(s.getMessage())) { + assertNull("more than one status with message " + message, found); + found = s; + } + } + assertNotNull("no status with message " + message, found); + return found; + } + + @Test + public void compositeKeywordWithoutRegisteredConverterBecomesParserError() throws Exception { + Parser p = new Parser("a %foo(x) b"); + p.setContext(context); + Node t = p.parse(); + Converter head = p.compile(t, converterMap); + + assertEquals("a %PARSER_ERROR[foo] b", write(head, new Object())); + Status missing = statusWithMessage( + "There is no conversion class registered for composite conversion word [foo]"); + assertEquals(Status.ERROR, missing.getLevel()); + Status failed = statusWithMessage("Failed to create converter for [%foo] keyword"); + assertEquals(Status.ERROR, failed.getLevel()); + } + + @Test + public void compositeKeywordMappedToNonCompositeConverterBecomesParserError() throws Exception { + Parser p = new Parser("%hello(x)"); + p.setContext(context); + Node t = p.parse(); + Converter head = p.compile(t, converterMap); + + assertEquals("%PARSER_ERROR[hello]", write(head, new Object())); + Status instantiation = statusWithMessage("Failed to instantiate converter class [" + + ConverterHello.class.getName() + "] as a composite converter for keyword [hello]"); + assertEquals(Status.ERROR, instantiation.getLevel()); + assertTrue(instantiation.getThrowable() instanceof IncompatibleClassException); + Status failed = statusWithMessage("Failed to create converter for [%hello] keyword"); + assertEquals(Status.ERROR, failed.getLevel()); + } + + @Test + public void keywordMappedToMissingClassBecomesParserError() throws Exception { + converterMap.put("missing", "no.such.Converter"); + Parser p = new Parser("a %missing b"); + p.setContext(context); + Node t = p.parse(); + Converter head = p.compile(t, converterMap); + + assertEquals("a %PARSER_ERROR[missing] b", write(head, new Object())); + Status instantiation = statusWithMessage( + "Failed to instantiate converter class [no.such.Converter] for keyword [missing]"); + assertEquals(Status.ERROR, instantiation.getLevel()); + assertTrue(instantiation.getThrowable() instanceof DynamicClassLoadingException); + Status invalid = statusWithMessage("[missing] is not a valid conversion word"); + assertEquals(Status.ERROR, invalid.getLevel()); + } + + @Test + public void nodesOfUnknownTypeAreSkipped() { + Node top = new Node(99, "ignored"); + top.setNext(new Node(Node.LITERAL, "a")); + Compiler compiler = new Compiler(top, converterMap); + compiler.setContext(context); + + Converter head = compiler.compile(); + + assertEquals("a", write(head, new Object())); + assertNull(head.getNext()); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } + } diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/CompositeNodeTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/CompositeNodeTest.java new file mode 100644 index 000000000..5ebc5c43f --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/CompositeNodeTest.java @@ -0,0 +1,98 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.pattern.parser; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; + +import org.junit.Test; + +public class CompositeNodeTest { + + static CompositeNode composite(String keyword, Node child) { + CompositeNode n = new CompositeNode(keyword); + n.setChildNode(child); + return n; + } + + static Node literal(String value) { + return new Node(Node.LITERAL, value); + } + + @Test + public void compositeNodeHasCompositeKeywordType() { + Node child = literal("a"); + CompositeNode n = composite("k", child); + assertEquals(Node.COMPOSITE_KEYWORD, n.getType()); + assertEquals("k", n.getValue()); + assertSame(child, n.getChildNode()); + assertNull(new CompositeNode("k").getChildNode()); + } + + @Test + public void nodesWithEqualChildrenAreEqual() { + assertTrue(composite("k", literal("a")).equals(composite("k", literal("a")))); + assertTrue(composite("k", null).equals(composite("k", null))); + } + + @Test + public void nodesDifferingAsKeywordNodesAreNotEqual() { + CompositeNode withOptions = composite("k", null); + withOptions.setOptions(Arrays.asList("o")); + assertFalse(withOptions.equals(composite("k", null))); + } + + @Test + public void compositeNodeDoesNotEqualKeywordNodeOfSameType() { + SimpleKeywordNode keywordNode = new SimpleKeywordNode(Node.COMPOSITE_KEYWORD, "k"); + assertFalse(composite("k", null).equals(keywordNode)); + } + + @Test + public void nodesWithDifferentChildrenAreNotEqual() { + assertFalse(composite("k", literal("a")).equals(composite("k", literal("b")))); + assertFalse(composite("k", literal("a")).equals(composite("k", null))); + assertFalse(composite("k", null).equals(composite("k", literal("a")))); + } + + @Test + public void hashCodeIsKeywordNodeHashCode() { + SimpleKeywordNode keywordNode = new SimpleKeywordNode(Node.COMPOSITE_KEYWORD, "k"); + assertEquals(keywordNode.hashCode(), composite("k", literal("a")).hashCode()); + } + + @Test + public void toStringShowsChild() { + assertEquals("CompositeNode(LITERAL(a))", composite("k", literal("a")).toString()); + } + + @Test + public void toStringWithoutChild() { + assertEquals("CompositeNode(no child)", composite("k", null).toString()); + } + + @Test + public void toStringAppendsTheRestOfTheChain() { + CompositeNode n = composite("k", literal("a")); + n.setNext(literal("b")); + assertEquals("CompositeNode(LITERAL(a)) -> LITERAL(b)", n.toString()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/FormattingNodeTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/FormattingNodeTest.java new file mode 100644 index 000000000..dd3f2148c --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/FormattingNodeTest.java @@ -0,0 +1,80 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.pattern.parser; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import ch.qos.logback.core.pattern.FormatInfo; + +public class FormattingNodeTest { + + static FormattingNode node(Object value, FormatInfo formatInfo) { + FormattingNode n = new FormattingNode(Node.SIMPLE_KEYWORD, value); + n.setFormatInfo(formatInfo); + return n; + } + + @Test + public void typeOnlyConstructorLeavesValueAndFormatInfoNull() { + FormattingNode n = new FormattingNode(Node.COMPOSITE_KEYWORD); + assertEquals(Node.COMPOSITE_KEYWORD, n.getType()); + assertNull(n.getValue()); + assertNull(n.getFormatInfo()); + } + + @Test + public void setFormatInfoIsReturnedByGetter() { + FormatInfo fi = new FormatInfo(1, 2); + assertSame(fi, node("x", fi).getFormatInfo()); + } + + @Test + public void nodesWithSameFormatInfoAreEqual() { + assertTrue(node("x", new FormatInfo(1, 2)).equals(node("x", new FormatInfo(1, 2)))); + assertTrue(node("x", null).equals(node("x", null))); + } + + @Test + public void nodesDifferingAsPlainNodesAreNotEqual() { + assertFalse(node("x", new FormatInfo(1, 2)).equals(node("y", new FormatInfo(1, 2)))); + } + + @Test + public void formattingNodeDoesNotEqualPlainNode() { + assertFalse(node("x", null).equals(new Node(Node.SIMPLE_KEYWORD, "x"))); + } + + @Test + public void nodesWithDifferentFormatInfoAreNotEqual() { + assertFalse(node("x", new FormatInfo(1, 2)).equals(node("x", new FormatInfo(1, 3)))); + assertFalse(node("x", new FormatInfo(1, 2)).equals(node("x", null))); + assertFalse(node("x", null).equals(node("x", new FormatInfo(1, 2)))); + } + + @Test + public void hashCodeCombinesNodeHashAndFormatInfo() { + int nodeHash = new Node(Node.SIMPLE_KEYWORD, "x").hashCode(); + FormatInfo fi = new FormatInfo(1, 2); + assertEquals(31 * nodeHash + fi.hashCode(), node("x", fi).hashCode()); + assertEquals(31 * nodeHash, node("x", null).hashCode()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/NodeTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/NodeTest.java new file mode 100644 index 000000000..e20b6be2a --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/NodeTest.java @@ -0,0 +1,127 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.pattern.parser; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class NodeTest { + + static Node literal(String value) { + return new Node(Node.LITERAL, value); + } + + static Node chain(Node first, Node next) { + first.setNext(next); + return first; + } + + @Test + public void typeOnlyConstructorLeavesValueNull() { + Node n = new Node(Node.SIMPLE_KEYWORD); + assertEquals(Node.SIMPLE_KEYWORD, n.getType()); + assertNull(n.getValue()); + assertNull(n.getNext()); + } + + @Test + public void setNextLinksNodes() { + Node first = literal("a"); + Node second = literal("b"); + first.setNext(second); + assertSame(second, first.getNext()); + } + + @Test + public void nodeEqualsItself() { + Node n = literal("a"); + assertTrue(n.equals(n)); + } + + @Test + public void nodeDoesNotEqualNonNode() { + assertFalse(literal("a").equals("a")); + assertFalse(literal("a").equals(null)); + } + + @Test + public void nodesWithDifferentTypesAreNotEqual() { + assertFalse(literal("a").equals(new Node(Node.SIMPLE_KEYWORD, "a"))); + } + + @Test + public void nodesWithDifferentValuesAreNotEqual() { + assertFalse(literal("a").equals(literal("b"))); + assertFalse(literal("a").equals(new Node(Node.LITERAL))); + assertFalse(new Node(Node.LITERAL).equals(literal("a"))); + } + + @Test + public void valuelessNodesOfSameTypeAreEqual() { + assertTrue(new Node(Node.LITERAL).equals(new Node(Node.LITERAL))); + } + + @Test + public void equalsComparesTheRestOfTheChain() { + assertTrue(chain(literal("a"), literal("b")).equals(chain(literal("a"), literal("b")))); + assertFalse(chain(literal("a"), literal("b")).equals(chain(literal("a"), literal("c")))); + assertFalse(chain(literal("a"), literal("b")).equals(literal("a"))); + assertFalse(literal("a").equals(chain(literal("a"), literal("b")))); + } + + @Test + public void hashCodeCombinesTypeAndValue() { + assertEquals(31 * Node.SIMPLE_KEYWORD + "x".hashCode(), new Node(Node.SIMPLE_KEYWORD, "x").hashCode()); + assertEquals(31 * Node.SIMPLE_KEYWORD, new Node(Node.SIMPLE_KEYWORD).hashCode()); + } + + @Test + public void hashCodeIgnoresNext() { + assertEquals(literal("a").hashCode(), chain(literal("a"), literal("b")).hashCode()); + } + + @Test + public void literalToStringShowsValue() { + assertEquals("LITERAL(a)", literal("a").toString()); + } + + @Test + public void toStringAppendsTheRestOfTheChain() { + assertEquals("LITERAL(a) -> LITERAL(b) -> LITERAL(c)", + chain(literal("a"), chain(literal("b"), literal("c"))).toString()); + } + + @Test + public void nonLiteralToStringFallsBackToObjectToString() { + Node n = new Node(Node.SIMPLE_KEYWORD, "x"); + String identity = Node.class.getName() + "@" + Integer.toHexString(n.hashCode()); + assertEquals(identity, n.toString()); + + n.setNext(literal("y")); + assertEquals(identity + " -> LITERAL(y)", n.toString()); + } + + @Test + public void printNextIsEmptyAtEndOfChain() { + assertEquals("", literal("a").printNext()); + assertEquals(" -> LITERAL(b)", chain(literal("a"), literal("b")).printNext()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/OptionTokenizerTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/OptionTokenizerTest.java index 1c6456625..386270009 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/OptionTokenizerTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/OptionTokenizerTest.java @@ -17,6 +17,11 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import org.junit.Test; @@ -39,6 +44,66 @@ public void escapeConsumingLastCharOfQuotedOptionIsReportedAsScanException() { assertEquals(UNEXPECTED_END, e.getMessage()); } + @Test + public void unterminatedQuotedOptionEndingWithCurlyBraceIsRejected() { + // the '}' is inside the still-open quote, so it does not close the option list + ScanException e = assertThrows(ScanException.class, + () -> new TokenStream("%x{'abc}").tokenize()); + assertEquals(UNEXPECTED_END, e.getMessage()); + } + + @Test + public void optionWithoutClosingCurlyBraceIsRejected() { + ScanException e = assertThrows(ScanException.class, + () -> new TokenStream("%x{abc").tokenize()); + assertEquals(UNEXPECTED_END, e.getMessage()); + } + + @Test + public void quotedOptionWithoutClosingCurlyBraceIsRejected() { + ScanException e = assertThrows(ScanException.class, + () -> new TokenStream("%x{'abc'").tokenize()); + assertEquals(UNEXPECTED_END, e.getMessage()); + } + + @Test + public void escapedQuoteDoesNotCloseQuotedOptionAndIsKeptAsIs() throws ScanException { + List tokens = new TokenStream("%x{'a\\'b'}").tokenize(); + + assertEquals(3, tokens.size()); + Token option = tokens.get(2); + assertEquals(Token.OPTION, option.getType()); + assertEquals(Collections.singletonList("a\\'b"), option.getOptionsList()); + } + + @Test + public void escapeAtEndOfPatternIsNoOp() { + TokenStream ts = new TokenStream("x"); + ts.pointer = 1; + OptionTokenizer ot = new OptionTokenizer(ts); + StringBuffer buf = new StringBuffer("a"); + + ot.escape("'", buf); + + assertEquals("a", buf.toString()); + assertEquals(1, ts.pointer); + } + + @Test + public void unknownStateConsumesRestOfPatternWithoutEmittingOption() { + TokenStream ts = new TokenStream("ab}"); + ts.pointer = 1; + OptionTokenizer ot = new OptionTokenizer(ts); + ot.state = 42; + List tokens = new ArrayList(); + + ScanException e = assertThrows(ScanException.class, () -> ot.tokenize('a', tokens)); + + assertEquals(UNEXPECTED_END, e.getMessage()); + assertTrue(tokens.isEmpty()); + assertEquals(3, ts.pointer); + } + // // @Test // public void testEmpty() throws ScanException { diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/ParserTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/ParserTest.java index 85f5a7bec..658d040a2 100755 --- a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/ParserTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/ParserTest.java @@ -17,13 +17,17 @@ import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.fail; +import static org.junit.Assert.assertThrows; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.regex.Pattern; import ch.qos.logback.core.Context; import ch.qos.logback.core.ContextBase; import ch.qos.logback.core.spi.ScanException; +import ch.qos.logback.core.status.Status; import ch.qos.logback.core.status.StatusChecker; import org.junit.Test; @@ -290,4 +294,72 @@ public void lbcore193() throws Exception { sc.assertContainsMatch("See also " + Parser.MISSING_RIGHT_PARENTHESIS); } + /** A token stream that yields the given tokens instead of tokenizing a pattern. */ + static TokenStream fixedTokens(final Token... tokens) { + return new TokenStream("unused") { + @Override + List tokenize() { + return Arrays.asList(tokens); + } + }; + } + + @Test + public void tokenStreamConstructorParsesTheStreamsTokens() throws Exception { + Parser p = new Parser(new TokenStream("a%x")); + Node witness = new Node(Node.LITERAL, "a"); + witness.next = new SimpleKeywordNode("x"); + assertEquals(witness, p.parse()); + } + + @Test + public void compositeKeepsOptionsFollowingItsClosingParenthesis() throws Exception { + Parser p = new Parser("%replace(%msg){'a', 'b'} x"); + Node t = p.parse(); + CompositeNode witness = new CompositeNode("replace"); + witness.setChildNode(new SimpleKeywordNode("msg")); + witness.setOptions(Arrays.asList("a", "b")); + witness.next = new Node(Node.LITERAL, " x"); + assertEquals(witness, t); + } + + @Test + public void patternWithoutTokensIsRejected() throws Exception { + // a lone trailing backslash produces no token at all + Parser p = new Parser("\\"); + IllegalStateException e = assertThrows(IllegalStateException.class, p::parse); + assertEquals("All tokens consumed but was expecting a LITERAL or '%'", e.getMessage()); + } + + @Test + public void compositeWithoutContentIsRejected() throws Exception { + Parser p = new Parser("%("); + IllegalStateException e = assertThrows(IllegalStateException.class, p::parse); + assertEquals("All tokens consumed but was expecting a LITERAL or '%'", e.getMessage()); + } + + @Test + public void percentFollowedByNonKeywordTokenIsRejected() throws Exception { + Parser p = new Parser( + fixedTokens(Token.PERCENT_TOKEN, new Token(Token.LITERAL, "x"))); + IllegalStateException e = assertThrows(IllegalStateException.class, p::parse); + assertEquals("Unexpected token Token(LITERAL, \"x\")", e.getMessage()); + } + + @Test + public void compositeClosedByNonParenthesisTokenIsRejected() throws Exception { + Parser p = new Parser(fixedTokens(Token.PERCENT_TOKEN, + Token.BARE_COMPOSITE_KEYWORD_TOKEN, new Token(Token.LITERAL, "a"), + new Token(Token.SIMPLE_KEYWORD, "b"))); + p.setContext(context); + + ScanException e = assertThrows(ScanException.class, p::parse); + + String expected = "Expecting RIGHT_PARENTHESIS token but got Token(SIMPLE_KEYWORD, \"b\")"; + assertEquals(expected, e.getMessage()); + StatusChecker sc = new StatusChecker(context); + sc.assertContainsMatch(Status.ERROR, Pattern.quote(expected)); + sc.assertContainsMatch(Status.ERROR, Pattern.quote("See also " + Parser.MISSING_RIGHT_PARENTHESIS)); + } + } \ No newline at end of file diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/SimpleKeywordNodeTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/SimpleKeywordNodeTest.java new file mode 100644 index 000000000..9fc566dc6 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/SimpleKeywordNodeTest.java @@ -0,0 +1,99 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.pattern.parser; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.List; + +import org.junit.Test; + +import ch.qos.logback.core.pattern.FormatInfo; + +public class SimpleKeywordNodeTest { + + static SimpleKeywordNode keyword(String value, List options) { + SimpleKeywordNode n = new SimpleKeywordNode(value); + n.setOptions(options); + return n; + } + + @Test + public void keywordNodeHasSimpleKeywordType() { + SimpleKeywordNode n = keyword("x", Arrays.asList("a")); + assertEquals(Node.SIMPLE_KEYWORD, n.getType()); + assertEquals("x", n.getValue()); + assertEquals(Arrays.asList("a"), n.getOptions()); + } + + @Test + public void nodesWithSameOptionsAreEqual() { + assertTrue(keyword("x", Arrays.asList("a")).equals(keyword("x", Arrays.asList("a")))); + assertTrue(keyword("x", null).equals(keyword("x", null))); + } + + @Test + public void nodesDifferingAsFormattingNodesAreNotEqual() { + SimpleKeywordNode formatted = keyword("x", null); + formatted.setFormatInfo(new FormatInfo(1, 2)); + assertFalse(formatted.equals(keyword("x", null))); + } + + @Test + public void keywordNodeDoesNotEqualPlainFormattingNode() { + assertFalse(keyword("x", null).equals(new FormattingNode(Node.SIMPLE_KEYWORD, "x"))); + } + + @Test + public void nodesWithDifferentOptionsAreNotEqual() { + assertFalse(keyword("x", Arrays.asList("a")).equals(keyword("x", Arrays.asList("b")))); + assertFalse(keyword("x", Arrays.asList("a")).equals(keyword("x", null))); + assertFalse(keyword("x", null).equals(keyword("x", Arrays.asList("a")))); + } + + @Test + public void hashCodeIsFormattingNodeHashCode() { + SimpleKeywordNode n = keyword("x", Arrays.asList("a")); + n.setFormatInfo(new FormatInfo(1, 2)); + FormattingNode f = new FormattingNode(Node.SIMPLE_KEYWORD, "x"); + f.setFormatInfo(new FormatInfo(1, 2)); + assertEquals(f.hashCode(), n.hashCode()); + } + + @Test + public void toStringWithoutOptions() { + assertEquals("KeyWord(x,null)", keyword("x", null).toString()); + + SimpleKeywordNode formatted = keyword("x", null); + formatted.setFormatInfo(new FormatInfo(1, 2)); + assertEquals("KeyWord(x,FormatInfo(1, 2, true, true))", formatted.toString()); + } + + @Test + public void toStringWithOptions() { + assertEquals("KeyWord(x, null,[a, b])", keyword("x", Arrays.asList("a", "b")).toString()); + } + + @Test + public void toStringAppendsTheRestOfTheChain() { + SimpleKeywordNode n = keyword("x", null); + n.setNext(new Node(Node.LITERAL, "y")); + assertEquals("KeyWord(x,null) -> LITERAL(y)", n.toString()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/TokenStreamTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/TokenStreamTest.java index 815b00f81..d3fef0f19 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/TokenStreamTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/TokenStreamTest.java @@ -422,6 +422,13 @@ public void compositedKeywordFollowedByOptions() throws ScanException { } } + @Test + public void nullPatternIsRejected() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> new TokenStream(null)); + assertEquals("null or empty pattern string not allowed", e.getMessage()); + } + @Test public void patternEndingInFormatModifierIsRejected() { ScanException e = assertThrows(ScanException.class, @@ -442,4 +449,60 @@ public void patternEndingInOpeningCurlyBraceIsRejected() { () -> new TokenStream("%x{").tokenize()); assertEquals("Unexpected end of pattern string", e.getMessage()); } + + @Test + public void escapedCurlyBraceAfterRightParenthesisIsLiteral() throws ScanException { + List tl = new TokenStream("%(x)\\{y}").tokenize(); + List witness = new ArrayList(); + witness.add(Token.PERCENT_TOKEN); + witness.add(Token.BARE_COMPOSITE_KEYWORD_TOKEN); + witness.add(new Token(Token.LITERAL, "x")); + witness.add(Token.RIGHT_PARENTHESIS_TOKEN); + witness.add(new Token(Token.LITERAL, "{y}")); + assertEquals(witness, tl); + } + + @Test + public void trailingBackslashAfterKeywordIsDropped() throws ScanException { + List tl = new TokenStream("%x\\").tokenize(); + List witness = new ArrayList(); + witness.add(Token.PERCENT_TOKEN); + witness.add(new Token(Token.SIMPLE_KEYWORD, "x")); + assertEquals(witness, tl); + } + + @Test + public void trailingBackslashAfterLiteralIsDropped() throws ScanException { + List tl = new TokenStream("abc\\").tokenize(); + List witness = new ArrayList(); + witness.add(new Token(Token.LITERAL, "abc")); + assertEquals(witness, tl); + } + + @Test + public void optionEscapeKeepsOnlyListedCharsUnescaped() { + TokenStream ts = new TokenStream("x'b"); + ts.pointer = 1; + StringBuffer buf = new StringBuffer(); + + ts.optionEscape("'", buf); + assertEquals("'", buf.toString()); + assertEquals(2, ts.pointer); + + ts.optionEscape("'", buf); + assertEquals("'\\b", buf.toString()); + assertEquals(3, ts.pointer); + } + + @Test + public void optionEscapeAtEndOfPatternIsNoOp() { + TokenStream ts = new TokenStream("x"); + ts.pointer = 1; + StringBuffer buf = new StringBuffer("a"); + + ts.optionEscape("'", buf); + + assertEquals("a", buf.toString()); + assertEquals(1, ts.pointer); + } } \ No newline at end of file diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/TokenTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/TokenTest.java new file mode 100644 index 000000000..03ce6ce5d --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/TokenTest.java @@ -0,0 +1,119 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.pattern.parser; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.Test; + +public class TokenTest { + + @Test + public void constructorsSetTypeValueAndOptions() { + List options = Arrays.asList("a", "b"); + + Token typeOnly = new Token(Token.PERCENT); + assertEquals(Token.PERCENT, typeOnly.getType()); + assertNull(typeOnly.getValue()); + assertNull(typeOnly.getOptionsList()); + + Token withValue = new Token(Token.LITERAL, "v"); + assertEquals(Token.LITERAL, withValue.getType()); + assertEquals("v", withValue.getValue()); + assertNull(withValue.getOptionsList()); + + Token withOptions = new Token(Token.OPTION, options); + assertEquals(Token.OPTION, withOptions.getType()); + assertNull(withOptions.getValue()); + assertSame(options, withOptions.getOptionsList()); + + Token full = new Token(Token.SIMPLE_KEYWORD, "k", options); + assertEquals(Token.SIMPLE_KEYWORD, full.getType()); + assertEquals("k", full.getValue()); + assertSame(options, full.getOptionsList()); + } + + @Test + public void toStringNamesEachTokenType() { + assertEquals("Token(%)", Token.PERCENT_TOKEN.toString()); + assertEquals("Token(FormatModifier, \"-5\")", new Token(Token.FORMAT_MODIFIER, "-5").toString()); + assertEquals("Token(LITERAL, \"abc\")", new Token(Token.LITERAL, "abc").toString()); + assertEquals("Token(OPTION)", new Token(Token.OPTION, Collections.singletonList("o")).toString()); + assertEquals("Token(SIMPLE_KEYWORD, \"x\")", new Token(Token.SIMPLE_KEYWORD, "x").toString()); + assertEquals("Token(COMPOSITE_KEYWORD, \"BARE\")", Token.BARE_COMPOSITE_KEYWORD_TOKEN.toString()); + assertEquals("Token(RIGHT_PARENTHESIS)", Token.RIGHT_PARENTHESIS_TOKEN.toString()); + assertEquals("Token(UNKNOWN, \"EOF\")", Token.EOF_TOKEN.toString()); + assertEquals("Token(UNKNOWN)", new Token(-1).toString()); + } + + @Test + public void hashCodeCombinesTypeAndValue() { + assertEquals(29 * Token.LITERAL + "abc".hashCode(), new Token(Token.LITERAL, "abc").hashCode()); + assertEquals(29 * Token.PERCENT, new Token(Token.PERCENT).hashCode()); + } + + @Test + public void equalTokensHaveEqualHashCodes() { + Token a = new Token(Token.LITERAL, "abc"); + Token b = new Token(Token.LITERAL, "abc"); + assertTrue(a.equals(b)); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + public void tokenEqualsItself() { + Token t = new Token(Token.LITERAL, "abc"); + assertTrue(t.equals(t)); + } + + @Test + public void tokenDoesNotEqualNonToken() { + assertFalse(new Token(Token.LITERAL, "abc").equals("abc")); + assertFalse(new Token(Token.LITERAL, "abc").equals(null)); + } + + @Test + public void tokensWithDifferentTypesAreNotEqual() { + assertFalse(new Token(Token.LITERAL, "x").equals(new Token(Token.SIMPLE_KEYWORD, "x"))); + } + + @Test + public void tokensWithDifferentValuesAreNotEqual() { + assertFalse(new Token(Token.LITERAL, "x").equals(new Token(Token.LITERAL, "y"))); + assertFalse(new Token(Token.LITERAL, "x").equals(new Token(Token.LITERAL))); + assertFalse(new Token(Token.LITERAL).equals(new Token(Token.LITERAL, "x"))); + } + + @Test + public void valuelessTokensOfSameTypeAreEqual() { + assertTrue(new Token(Token.RIGHT_PARENTHESIS).equals(Token.RIGHT_PARENTHESIS_TOKEN)); + } + + @Test + public void equalsIgnoresOptionsList() { + Token a = new Token(Token.OPTION, Collections.singletonList("a")); + Token b = new Token(Token.OPTION, Collections.singletonList("b")); + assertTrue(a.equals(b)); + } +} From 92885d54441dbfee4f95d8f69dd6909a75ed1e41 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:33:01 +0000 Subject: [PATCH 45/79] test: pin option trimming and escape after ')' in the pattern tokenizers Mutation checks showed that dropping trim() on unquoted options, or the escape() call after a right parenthesis, left every parser test green: Token.equals ignores option lists, and an escaped '{' reads the same whether or not it is escaped. Assert the option lists directly and use an escaped '%' whose meaning changes without the escape. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../pattern/parser/OptionTokenizerTest.java | 22 +++++++++++++++++++ .../core/pattern/parser/TokenStreamTest.java | 13 +++++++++++ 2 files changed, 35 insertions(+) diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/OptionTokenizerTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/OptionTokenizerTest.java index 386270009..c004da87b 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/OptionTokenizerTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/OptionTokenizerTest.java @@ -20,6 +20,7 @@ import static org.junit.Assert.assertTrue; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -76,6 +77,27 @@ public void escapedQuoteDoesNotCloseQuotedOptionAndIsKeptAsIs() throws ScanExcep assertEquals(Collections.singletonList("a\\'b"), option.getOptionsList()); } + @Test + public void unquotedOptionsAreTrimmedButQuotedOptionsAreKeptVerbatim() throws ScanException { + List tokens = new TokenStream("%x{ a b , ' c ' , d } z").tokenize(); + + assertEquals(4, tokens.size()); + Token option = tokens.get(2); + assertEquals(Token.OPTION, option.getType()); + assertEquals(Arrays.asList("a b", " c ", "d"), option.getOptionsList()); + assertEquals(new Token(Token.LITERAL, " z"), tokens.get(3)); + } + + @Test + public void lastUnquotedOptionIsTrimmedWhenItsCurlyBraceEndsThePattern() throws ScanException { + List tokens = new TokenStream("%x{ a , b }").tokenize(); + + assertEquals(3, tokens.size()); + Token option = tokens.get(2); + assertEquals(Token.OPTION, option.getType()); + assertEquals(Arrays.asList("a", "b"), option.getOptionsList()); + } + @Test public void escapeAtEndOfPatternIsNoOp() { TokenStream ts = new TokenStream("x"); diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/TokenStreamTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/TokenStreamTest.java index d3fef0f19..30fdc2e5e 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/TokenStreamTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/TokenStreamTest.java @@ -462,6 +462,19 @@ public void escapedCurlyBraceAfterRightParenthesisIsLiteral() throws ScanExcepti assertEquals(witness, tl); } + @Test + public void escapedPercentAfterRightParenthesisIsLiteral() throws ScanException { + // without the escape, '%' would start a new conversion word + List tl = new TokenStream("%(x)\\%y").tokenize(); + List witness = new ArrayList(); + witness.add(Token.PERCENT_TOKEN); + witness.add(Token.BARE_COMPOSITE_KEYWORD_TOKEN); + witness.add(new Token(Token.LITERAL, "x")); + witness.add(Token.RIGHT_PARENTHESIS_TOKEN); + witness.add(new Token(Token.LITERAL, "%y")); + assertEquals(witness, tl); + } + @Test public void trailingBackslashAfterKeywordIsDropped() throws ScanException { List tl = new TokenStream("%x\\").tokenize(); From f9acb7bbbd19fd4dd684d0cd1e9bb91d4c62af0b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:13:57 +0000 Subject: [PATCH 46/79] test: cover classic.pattern converters and abbreviators Close the JaCoCo line/branch gaps of ch.qos.logback.classic.pattern. The tests pin: - CallerDataConverter and ThrowableProxyConverter: depth/length and range parsing with the error statuses for unparsable, negative and inverted values; evaluator lookup from the context's evaluator map; matching, non-matching and failing evaluators (errors reported up to the limit, then one final nested status and silence); ignored stack trace lines, including trailing skipped frames; stop() dropping the evaluators. - the caller-data converters (class/file/line/method) returning "?" for missing or empty caller data, and NamedConverter's abbreviator choice for zero, positive, negative and non-numeric lengths. - RootCauseFirstThrowableProxyConverter printing suppressed exceptions with their prefix and tolerating a null suppressed array. - MDC, marker and property converters' fallbacks (default value without an MDC map, empty/absent markers, system property fallback, no key). - SyslogStartConverter reporting a missing facility and a date format setup failure (injected with Mockito) and staying stopped. - EnsureExceptionHandling rejecting an empty chain, the abbreviators' edge cases, LocalSequenceNumberConverter's sequence and the no-op hooks (handlesThrowable, prepareLoggingEvent). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../pattern/CallerDataConverterTest.java | 280 ++++++++++++++++++ .../pattern/ClassNameOnlyAbbreviatorTest.java | 45 +++ .../pattern/ClassOfCallerConverterTest.java | 68 +++++ .../pattern/EnsureExceptionHandlingTest.java | 80 +++++ .../ExtendedThrowableProxyConverterTest.java | 9 + .../pattern/FileOfCallerConverterTest.java | 54 ++++ .../pattern/LineOfCallerConverterTest.java | 54 ++++ .../LocalSequenceNumberConverterTest.java | 63 ++++ .../classic/pattern/MDCConverterTest.java | 34 +++ .../classic/pattern/MarkerConverterTest.java | 15 + .../pattern/MethodOfCallerConverterTest.java | 54 ++++ .../classic/pattern/NamedConverterTest.java | 82 +++++ .../pattern/PropertyConverterTest.java | 91 ++++++ ...CauseFirstThrowableProxyConverterTest.java | 49 +++ .../classic/pattern/StubEventEvaluator.java | 71 +++++ .../pattern/SyslogStartConverterTest.java | 43 +++ ...etLengthBasedClassNameAbbreviatorTest.java | 30 ++ .../ThrowableHandlingConverterTest.java | 44 +++ .../pattern/ThrowableProxyConverterTest.java | 165 +++++++++++ 19 files changed, 1331 insertions(+) create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/pattern/CallerDataConverterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/pattern/ClassNameOnlyAbbreviatorTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/pattern/ClassOfCallerConverterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/pattern/EnsureExceptionHandlingTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/pattern/FileOfCallerConverterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/pattern/LineOfCallerConverterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/pattern/LocalSequenceNumberConverterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/pattern/MethodOfCallerConverterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/pattern/NamedConverterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/pattern/PropertyConverterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/pattern/StubEventEvaluator.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/pattern/ThrowableHandlingConverterTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/CallerDataConverterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/CallerDataConverterTest.java new file mode 100644 index 000000000..9b2454b56 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/CallerDataConverterTest.java @@ -0,0 +1,280 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.List; + +import org.junit.Test; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.CallerData; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.boolex.EvaluationException; +import ch.qos.logback.core.status.ErrorStatus; +import ch.qos.logback.core.status.Status; + +public class CallerDataConverterTest { + + private static final StackTraceElement[] CALLER_DATA = { + new StackTraceElement("a.A", "m0", "A.java", 10), + new StackTraceElement("b.B", "m1", "B.java", 20), + new StackTraceElement("c.C", "m2", "C.java", 30), + new StackTraceElement("d.D", "m3", "D.java", 40), + new StackTraceElement("e.E", "m4", "E.java", 50), + new StackTraceElement("f.F", "m5", "F.java", 60), + new StackTraceElement("g.G", "m6", "G.java", 70), + }; + + private final LoggerContext lc = new LoggerContext(); + private final Logger logger = lc.getLogger(CallerDataConverterTest.class); + + private CallerDataConverter startedConverter(String... options) { + return start(new CallerDataConverter(), options); + } + + private CallerDataConverter start(CallerDataConverter converter, String... options) { + converter.setContext(lc); + converter.setOptionList(Arrays.asList(options)); + converter.start(); + return converter; + } + + private LoggingEvent event() { + LoggingEvent le = new LoggingEvent(getClass().getName(), logger, Level.INFO, "msg", null, null); + le.setCallerData(CALLER_DATA); + return le; + } + + private static String callerLines(int from, int to) { + StringBuilder sb = new StringBuilder(); + for (int i = from; i < to; i++) { + sb.append("Caller+").append(i).append("\t at ").append(CALLER_DATA[i]) + .append(CoreConstants.LINE_SEPARATOR); + } + return sb.toString(); + } + + private List statuses() { + return lc.getStatusManager().getCopyOfStatusList(); + } + + private void assertSingleError(String message, Class throwableClass) { + List statuses = statuses(); + assertEquals(statuses.toString(), 1, statuses.size()); + Status status = statuses.get(0); + assertEquals(Status.ERROR, status.getLevel()); + assertEquals(message, status.getMessage()); + if (throwableClass == null) { + assertNull(status.getThrowable()); + } else { + assertTrue(throwableClass.isInstance(status.getThrowable())); + } + } + + @Test + public void withoutOptionsPrintsTopFiveFrames() { + CallerDataConverter converter = startedConverter(); + assertEquals(callerLines(0, 5), converter.convert(event())); + } + + @Test + public void depthBeyondCallerDataPrintsAllFrames() { + CallerDataConverter converter = startedConverter("9"); + assertEquals(callerLines(0, 7), converter.convert(event())); + } + + @Test + public void depthOptionLimitsNumberOfPrintedFrames() { + CallerDataConverter converter = startedConverter("2"); + assertEquals(callerLines(0, 2), converter.convert(event())); + assertTrue(statuses().isEmpty()); + } + + @Test + public void rangeOptionPrintsFramesFromStartUpToEnd() { + CallerDataConverter converter = startedConverter("1..2"); + assertEquals(callerLines(1, 2), converter.convert(event())); + assertTrue(statuses().isEmpty()); + } + + @Test + public void rangeMayStartAtTopFrame() { + CallerDataConverter converter = startedConverter("0..1"); + assertEquals(callerLines(0, 1), converter.convert(event())); + assertTrue(statuses().isEmpty()); + } + + @Test + public void rangeStartingBeyondCallerDataPrintsNotAvailable() { + CallerDataConverter converter = startedConverter("7..9"); + assertEquals(CallerData.CALLER_DATA_NA, converter.convert(event())); + } + + @Test + public void missingCallerDataPrintsNotAvailable() { + CallerDataConverter converter = startedConverter(); + ILoggingEvent event = mock(ILoggingEvent.class); + when(event.getCallerData()).thenReturn(null); + assertEquals(CallerData.CALLER_DATA_NA, converter.convert(event)); + } + + @Test + public void unparsableDepthIsReportedAndDefaultDepthKept() { + CallerDataConverter converter = startedConverter("abc"); + assertSingleError("Failed to parse depth option [abc]", NumberFormatException.class); + assertEquals(callerLines(0, 5), converter.convert(event())); + } + + @Test + public void unparsableRangeBoundIsReported() { + startedConverter("1..x"); + assertSingleError("Failed to parse depth option [1..x]", NumberFormatException.class); + } + + @Test + public void negativeRangeStartIsReported() { + startedConverter("-1..3"); + assertSingleError( + "Invalid depthStart/depthEnd range [-1, 3] (negative values are not allowed)", null); + } + + @Test + public void negativeRangeEndIsReported() { + startedConverter("1..-3"); + assertSingleError( + "Invalid depthStart/depthEnd range [1, -3] (negative values are not allowed)", null); + } + + @Test + public void rangeWithStartNotBelowEndIsReported() { + startedConverter("2..2"); + assertSingleError("Invalid depthEnd range [2, 2] (start greater or equal to end)", null); + } + + @Test + public void rangeThatDoesNotSplitIntoTwoBoundsIsReported() { + // With an empty delimiter every depth string "is" a range, but splitting the + // empty string yields a single part. + CallerDataConverter converter = new CallerDataConverter() { + @Override + protected String getDefaultRangeDelimiter() { + return ""; + } + }; + start(converter, ""); + assertSingleError("Failed to parse depth option as range []", null); + assertEquals(callerLines(0, 5), converter.convert(event())); + } + + @Test + public void depthSuppliedWithoutOptionListRegistersNoEvaluator() { + CallerDataConverter converter = new CallerDataConverter() { + @Override + public String getFirstOption() { + return "1"; + } + }; + converter.setContext(lc); + converter.start(); + + assertNull(converter.evaluatorList); + assertEquals(callerLines(0, 1), converter.convert(event())); + assertTrue(statuses().isEmpty()); + } + + @Test + public void evaluatorsNamedInOptionsAreLookedUpInContext() { + StubEventEvaluator e1 = new StubEventEvaluator("e1", false).registerIn(lc); + StubEventEvaluator e2 = new StubEventEvaluator("e2", false).registerIn(lc); + + CallerDataConverter converter = startedConverter("2", "e1", "unknown", "e2"); + + assertEquals(2, converter.evaluatorList.size()); + assertSame(e1, converter.evaluatorList.get(0)); + assertSame(e2, converter.evaluatorList.get(1)); + } + + @Test + public void callerDataPrintedOnceAnEvaluatorMatches() { + StubEventEvaluator noMatch = new StubEventEvaluator("noMatch", false).registerIn(lc); + StubEventEvaluator match = new StubEventEvaluator("match", true).registerIn(lc); + StubEventEvaluator notReached = new StubEventEvaluator("notReached", true).registerIn(lc); + CallerDataConverter converter = startedConverter("2", "noMatch", "match", "notReached"); + + assertEquals(callerLines(0, 2), converter.convert(event())); + assertEquals(1, noMatch.invocations); + assertEquals(1, match.invocations); + assertEquals("evaluation stops at the first match", 0, notReached.invocations); + } + + @Test + public void callerDataOmittedWhenNoEvaluatorMatches() { + StubEventEvaluator e1 = new StubEventEvaluator("e1", false).registerIn(lc); + StubEventEvaluator e2 = new StubEventEvaluator("e2", false).registerIn(lc); + CallerDataConverter converter = startedConverter("2", "e1", "e2"); + + assertEquals(CoreConstants.EMPTY_STRING, converter.convert(event())); + assertEquals(1, e1.invocations); + assertEquals(1, e2.invocations); + } + + @Test + public void evaluatorFailuresAreReportedUntilTheErrorLimit() { + EvaluationException failure = new EvaluationException("boom"); + StubEventEvaluator failing = new StubEventEvaluator("failing", failure).registerIn(lc); + CallerDataConverter converter = startedConverter("2", "failing"); + assertEquals(4, converter.MAX_ERROR_COUNT); + + for (int i = 0; i < 5; i++) { + // a failing evaluator does not count as a match + assertEquals(CoreConstants.EMPTY_STRING, converter.convert(event())); + } + + assertEquals(5, converter.errorCount); + List statuses = statuses(); + assertEquals(statuses.toString(), 4, statuses.size()); + for (int i = 0; i < 3; i++) { + Status status = statuses.get(i); + assertEquals(Status.ERROR, status.getLevel()); + assertEquals("Exception thrown for evaluator named [failing]", status.getMessage()); + assertSame(failure, status.getThrowable()); + assertSame(converter, status.getOrigin()); + } + Status last = statuses.get(3); + assertTrue(last instanceof ErrorStatus); + assertEquals("Exception thrown for evaluator named [failing].", last.getMessage()); + assertSame(failure, last.getThrowable()); + assertSame(converter, last.getOrigin()); + assertTrue(last.hasChildren()); + Status child = last.iterator().next(); + assertEquals(Status.ERROR, child.getLevel()); + assertEquals("This was the last warning about this evaluator's errors. " + + "We don't want the StatusManager to get flooded.", child.getMessage()); + assertSame(converter, child.getOrigin()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/ClassNameOnlyAbbreviatorTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/ClassNameOnlyAbbreviatorTest.java new file mode 100644 index 000000000..ed5ff8508 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/ClassNameOnlyAbbreviatorTest.java @@ -0,0 +1,45 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.pattern; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class ClassNameOnlyAbbreviatorTest { + + private final ClassNameOnlyAbbreviator abbreviator = new ClassNameOnlyAbbreviator(); + + @Test + public void stripsPackageFromQualifiedName() { + assertEquals("Foobar", abbreviator.abbreviate("com.logback.Foobar")); + } + + @Test + public void keepsInnerClassSeparator() { + assertEquals("Foobar$Inner", abbreviator.abbreviate("com.logback.Foobar$Inner")); + } + + @Test + public void returnsNameWithoutPackageUnchanged() { + assertEquals("Foobar", abbreviator.abbreviate("Foobar")); + } + + @Test + public void nameEndingWithDotAbbreviatesToEmpty() { + assertEquals("", abbreviator.abbreviate("com.logback.")); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/ClassOfCallerConverterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/ClassOfCallerConverterTest.java new file mode 100644 index 000000000..927592a3d --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/ClassOfCallerConverterTest.java @@ -0,0 +1,68 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.pattern; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collections; + +import org.junit.Test; + +import ch.qos.logback.classic.spi.CallerData; +import ch.qos.logback.classic.spi.ILoggingEvent; + +public class ClassOfCallerConverterTest { + + private final ClassOfCallerConverter converter = new ClassOfCallerConverter(); + + private static ILoggingEvent eventWithCallerData(StackTraceElement... callerData) { + ILoggingEvent event = mock(ILoggingEvent.class); + when(event.getCallerData()).thenReturn(callerData); + return event; + } + + @Test + public void convertsToClassOfFirstCallerFrame() { + converter.start(); + ILoggingEvent event = eventWithCallerData( + new StackTraceElement("com.example.First", "m", "First.java", 1), + new StackTraceElement("com.example.Second", "m", "Second.java", 2)); + assertEquals("com.example.First", converter.convert(event)); + } + + @Test + public void abbreviatesClassNameAccordingToOption() { + converter.setOptionList(Collections.singletonList("0")); + converter.start(); + ILoggingEvent event = eventWithCallerData( + new StackTraceElement("com.example.First", "m", "First.java", 1)); + assertEquals("First", converter.convert(event)); + } + + @Test + public void convertsToNotAvailableWithoutCallerData() { + converter.start(); + assertEquals(CallerData.NA, converter.convert(eventWithCallerData((StackTraceElement[]) null))); + } + + @Test + public void convertsToNotAvailableWithEmptyCallerData() { + converter.start(); + assertEquals(CallerData.NA, converter.convert(eventWithCallerData())); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/EnsureExceptionHandlingTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/EnsureExceptionHandlingTest.java new file mode 100644 index 000000000..873d559be --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/EnsureExceptionHandlingTest.java @@ -0,0 +1,80 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.pattern.Converter; +import ch.qos.logback.core.pattern.LiteralConverter; + +public class EnsureExceptionHandlingTest { + + private final LoggerContext lc = new LoggerContext(); + private final EnsureExceptionHandling processor = new EnsureExceptionHandling(); + + @Test + public void rejectsEmptyChain() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> processor.process(lc, null)); + assertEquals("cannot process empty chain", e.getMessage()); + } + + @Test + public void appendsThrowableConverterToChainWithoutOne() { + lc.setPackagingDataEnabled(false); + Converter head = new LiteralConverter("a"); + Converter tail = new LiteralConverter("b"); + head.setNext(tail); + + processor.process(lc, head); + + assertSame(tail, head.getNext()); + assertEquals(ThrowableProxyConverter.class, tail.getNext().getClass()); + assertNull(tail.getNext().getNext()); + } + + @Test + public void appendsExtendedThrowableConverterWhenPackagingDataIsEnabled() { + lc.setPackagingDataEnabled(true); + Converter head = new LiteralConverter("a"); + + processor.process(lc, head); + + assertEquals(ExtendedThrowableProxyConverter.class, head.getNext().getClass()); + } + + @Test + public void leavesChainThatHandlesThrowablesUntouched() { + Converter head = new LiteralConverter("a"); + ThrowableProxyConverter throwableConverter = new ThrowableProxyConverter(); + head.setNext(throwableConverter); + throwableConverter.setNext(new LiteralConverter("c")); + Converter tail = throwableConverter.getNext(); + + processor.process(lc, head); + + assertSame(throwableConverter, head.getNext()); + assertSame(tail, throwableConverter.getNext()); + assertNull(tail.getNext()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/ExtendedThrowableProxyConverterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/ExtendedThrowableProxyConverterTest.java index 30c46cf60..71c0f8fc9 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/pattern/ExtendedThrowableProxyConverterTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/ExtendedThrowableProxyConverterTest.java @@ -18,6 +18,8 @@ import static junit.framework.Assert.assertEquals; import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.junit.MatcherAssert.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; import java.io.PrintWriter; import java.io.StringWriter; @@ -90,6 +92,13 @@ public void nested() { verify(t); } + @Test + public void prepareLoggingEventLeavesEventUntouched() { + ILoggingEvent event = mock(ILoggingEvent.class); + etpc.prepareLoggingEvent(event); + verifyNoInteractions(event); + } + void verify(Throwable t) { t.printStackTrace(pw); diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/FileOfCallerConverterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/FileOfCallerConverterTest.java new file mode 100644 index 000000000..725ca5dcd --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/FileOfCallerConverterTest.java @@ -0,0 +1,54 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.pattern; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.Test; + +import ch.qos.logback.classic.spi.CallerData; +import ch.qos.logback.classic.spi.ILoggingEvent; + +public class FileOfCallerConverterTest { + + private final FileOfCallerConverter converter = new FileOfCallerConverter(); + + private static ILoggingEvent eventWithCallerData(StackTraceElement... callerData) { + ILoggingEvent event = mock(ILoggingEvent.class); + when(event.getCallerData()).thenReturn(callerData); + return event; + } + + @Test + public void convertsToFileOfFirstCallerFrame() { + ILoggingEvent event = eventWithCallerData( + new StackTraceElement("com.example.First", "m", "First.java", 1), + new StackTraceElement("com.example.Second", "m", "Second.java", 2)); + assertEquals("First.java", converter.convert(event)); + } + + @Test + public void convertsToNotAvailableWithoutCallerData() { + assertEquals(CallerData.NA, converter.convert(eventWithCallerData((StackTraceElement[]) null))); + } + + @Test + public void convertsToNotAvailableWithEmptyCallerData() { + assertEquals(CallerData.NA, converter.convert(eventWithCallerData())); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/LineOfCallerConverterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/LineOfCallerConverterTest.java new file mode 100644 index 000000000..0b708d6a1 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/LineOfCallerConverterTest.java @@ -0,0 +1,54 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.pattern; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.Test; + +import ch.qos.logback.classic.spi.CallerData; +import ch.qos.logback.classic.spi.ILoggingEvent; + +public class LineOfCallerConverterTest { + + private final LineOfCallerConverter converter = new LineOfCallerConverter(); + + private static ILoggingEvent eventWithCallerData(StackTraceElement... callerData) { + ILoggingEvent event = mock(ILoggingEvent.class); + when(event.getCallerData()).thenReturn(callerData); + return event; + } + + @Test + public void convertsToLineOfFirstCallerFrame() { + ILoggingEvent event = eventWithCallerData( + new StackTraceElement("com.example.First", "m", "First.java", 17), + new StackTraceElement("com.example.Second", "m", "Second.java", 2)); + assertEquals("17", converter.convert(event)); + } + + @Test + public void convertsToNotAvailableWithoutCallerData() { + assertEquals(CallerData.NA, converter.convert(eventWithCallerData((StackTraceElement[]) null))); + } + + @Test + public void convertsToNotAvailableWithEmptyCallerData() { + assertEquals(CallerData.NA, converter.convert(eventWithCallerData())); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/LocalSequenceNumberConverterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/LocalSequenceNumberConverterTest.java new file mode 100644 index 000000000..20a617820 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/LocalSequenceNumberConverterTest.java @@ -0,0 +1,63 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +import org.junit.Test; + +import ch.qos.logback.classic.spi.ILoggingEvent; + +public class LocalSequenceNumberConverterTest { + + private final ILoggingEvent event = mock(ILoggingEvent.class); + + @Test + public void sequenceStartsAtCreationTimeInMillis() { + long before = System.currentTimeMillis(); + LocalSequenceNumberConverter converter = new LocalSequenceNumberConverter(); + long after = System.currentTimeMillis(); + + long first = Long.parseLong(converter.convert(event)); + + assertTrue(first + " < " + before, first >= before); + assertTrue(first + " > " + after, first <= after); + } + + @Test + public void eachConversionYieldsTheNextNumber() { + LocalSequenceNumberConverter converter = new LocalSequenceNumberConverter(); + converter.sequenceNumber.set(41); + + assertEquals("41", converter.convert(event)); + assertEquals("42", converter.convert(event)); + assertEquals("43", converter.convert(event)); + } + + @Test + public void instancesKeepIndependentSequences() { + LocalSequenceNumberConverter first = new LocalSequenceNumberConverter(); + LocalSequenceNumberConverter second = new LocalSequenceNumberConverter(); + first.sequenceNumber.set(10); + second.sequenceNumber.set(100); + + assertEquals("10", first.convert(event)); + assertEquals("100", second.convert(event)); + assertEquals("11", first.convert(event)); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/MDCConverterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/MDCConverterTest.java index f709b0c21..8fe1ea0d2 100755 --- a/logback-android/src/test/java/ch/qos/logback/classic/pattern/MDCConverterTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/MDCConverterTest.java @@ -17,6 +17,8 @@ import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import ch.qos.logback.core.testUtil.RandomUtil; import org.junit.After; @@ -24,6 +26,8 @@ import org.junit.Test; import org.slf4j.MDC; +import java.util.Collections; + import ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.LoggerContext; @@ -73,6 +77,36 @@ public void testConvertWithMultipleEntries() { assertTrue(result + " is not conform", isConform); } + @Test + public void testConvertWithKeyAndDefaultValue() { + converter.stop(); + converter.setOptionList(Collections.singletonList("absentKey:-fallback")); + converter.start(); + MDC.put("otherKey", "otherValue"); + + assertEquals("fallback", converter.convert(createLoggingEvent())); + } + + @Test + public void testConvertWithoutMDCMapYieldsDefaultValue() { + converter.stop(); + converter.setOptionList(Collections.singletonList("someKey:-fallback")); + converter.start(); + + assertEquals("fallback", converter.convert(eventWithoutMDCMap())); + } + + @Test + public void testConvertWithoutMDCMapYieldsEmptyStringByDefault() { + assertEquals("", converter.convert(eventWithoutMDCMap())); + } + + private ILoggingEvent eventWithoutMDCMap() { + ILoggingEvent event = mock(ILoggingEvent.class); + when(event.getMDCPropertyMap()).thenReturn(null); + return event; + } + private ILoggingEvent createLoggingEvent() { return new LoggingEvent(this.getClass().getName(), lc .getLogger(Logger.ROOT_LOGGER_NAME), Level.DEBUG, "test message", null, diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/MarkerConverterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/MarkerConverterTest.java index 63ef7735a..feb44d6d1 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/pattern/MarkerConverterTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/MarkerConverterTest.java @@ -89,6 +89,21 @@ public void testWithSeveralChildMarker() { assertEquals("[testParent [ child1, child2, child3 ]]", result); } + @Test + public void testWithoutMarkers() { + LoggingEvent le = new LoggingEvent(this.getClass().getName(), lc.getLogger(Logger.ROOT_LOGGER_NAME), + Level.DEBUG, "test message", null, null); + assertEquals("", converter.convert(le)); + } + + @Test + public void testWithEmptyMarkerList() { + LoggingEvent le = new LoggingEvent(this.getClass().getName(), lc.getLogger(Logger.ROOT_LOGGER_NAME), + Level.DEBUG, "test message", null, null); + le.setMarkers(Collections.emptyList()); + assertEquals("", converter.convert(le)); + } + private ILoggingEvent createLoggingEvent(Marker marker) { LoggingEvent le = new LoggingEvent(this.getClass().getName(), lc.getLogger(Logger.ROOT_LOGGER_NAME), Level.DEBUG, "test message", null, null); diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/MethodOfCallerConverterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/MethodOfCallerConverterTest.java new file mode 100644 index 000000000..8ac043bf4 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/MethodOfCallerConverterTest.java @@ -0,0 +1,54 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.pattern; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.Test; + +import ch.qos.logback.classic.spi.CallerData; +import ch.qos.logback.classic.spi.ILoggingEvent; + +public class MethodOfCallerConverterTest { + + private final MethodOfCallerConverter converter = new MethodOfCallerConverter(); + + private static ILoggingEvent eventWithCallerData(StackTraceElement... callerData) { + ILoggingEvent event = mock(ILoggingEvent.class); + when(event.getCallerData()).thenReturn(callerData); + return event; + } + + @Test + public void convertsToMethodOfFirstCallerFrame() { + ILoggingEvent event = eventWithCallerData( + new StackTraceElement("com.example.First", "firstMethod", "First.java", 1), + new StackTraceElement("com.example.Second", "secondMethod", "Second.java", 2)); + assertEquals("firstMethod", converter.convert(event)); + } + + @Test + public void convertsToNotAvailableWithoutCallerData() { + assertEquals(CallerData.NA, converter.convert(eventWithCallerData((StackTraceElement[]) null))); + } + + @Test + public void convertsToNotAvailableWithEmptyCallerData() { + assertEquals(CallerData.NA, converter.convert(eventWithCallerData())); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/NamedConverterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/NamedConverterTest.java new file mode 100644 index 000000000..9e143d377 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/NamedConverterTest.java @@ -0,0 +1,82 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +import java.util.Collections; + +import org.junit.Test; + +import ch.qos.logback.classic.spi.ILoggingEvent; + +public class NamedConverterTest { + + private static final String FQN = "com.example.pkg.SomeClass"; + + private final ILoggingEvent event = mock(ILoggingEvent.class); + + private final NamedConverter converter = new NamedConverter() { + @Override + protected String getFullyQualifiedName(ILoggingEvent e) { + return FQN; + } + }; + + private void startWithOption(String option) { + converter.setOptionList(Collections.singletonList(option)); + converter.start(); + } + + @Test + public void withoutOptionReturnsFullyQualifiedName() { + converter.start(); + assertNull(converter.abbreviator); + assertEquals(FQN, converter.convert(event)); + } + + @Test + public void zeroLengthKeepsOnlyTheClassName() { + startWithOption("0"); + assertTrue(converter.abbreviator instanceof ClassNameOnlyAbbreviator); + assertEquals("SomeClass", converter.convert(event)); + } + + @Test + public void positiveLengthAbbreviatesPackages() { + startWithOption("17"); + assertTrue(converter.abbreviator instanceof TargetLengthBasedClassNameAbbreviator); + assertEquals(17, ((TargetLengthBasedClassNameAbbreviator) converter.abbreviator).targetLength); + assertEquals("c.e.pkg.SomeClass", converter.convert(event)); + } + + @Test + public void negativeLengthLeavesNameUnabbreviated() { + startWithOption("-1"); + assertNull(converter.abbreviator); + assertEquals(FQN, converter.convert(event)); + } + + @Test + public void nonNumericLengthLeavesNameUnabbreviated() { + startWithOption("abc"); + assertNull(converter.abbreviator); + assertEquals(FQN, converter.convert(event)); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/PropertyConverterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/PropertyConverterTest.java new file mode 100644 index 000000000..c5500bb05 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/PropertyConverterTest.java @@ -0,0 +1,91 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; + +import org.junit.After; +import org.junit.Test; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.core.testUtil.RandomUtil; + +public class PropertyConverterTest { + + private final String key = "PropertyConverterTest.key." + RandomUtil.getPositiveInt(); + private final LoggerContext lc = new LoggerContext(); + private final PropertyConverter converter = new PropertyConverter(); + + @After + public void tearDown() { + System.clearProperty(key); + } + + private ILoggingEvent event() { + Logger logger = lc.getLogger(Logger.ROOT_LOGGER_NAME); + return new LoggingEvent(getClass().getName(), logger, Level.INFO, "msg", null, null); + } + + private void startWithKey() { + converter.setContext(lc); + converter.setOptionList(Collections.singletonList(key)); + converter.start(); + } + + @Test + public void withoutKeyStaysStoppedAndSaysSo() { + converter.setContext(lc); + converter.start(); + + assertFalse(converter.isStarted()); + assertNull(converter.key); + assertEquals("Property_HAS_NO_KEY", converter.convert(event())); + } + + @Test + public void contextPropertyTakesPrecedenceOverSystemProperty() { + startWithKey(); + assertTrue(converter.isStarted()); + lc.putProperty(key, "fromContext"); + System.setProperty(key, "fromSystem"); + + assertEquals("fromContext", converter.convert(event())); + } + + @Test + public void fallsBackToSystemPropertyWhenContextLacksKey() { + startWithKey(); + System.setProperty(key, "fromSystem"); + + assertEquals("fromSystem", converter.convert(event())); + } + + @Test + public void convertsToNullWhenPropertyIsDefinedNowhere() { + startWithKey(); + + assertNull(converter.convert(event())); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/RootCauseFirstThrowableProxyConverterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/RootCauseFirstThrowableProxyConverterTest.java index a5b9f2204..cc1bbcdc2 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/pattern/RootCauseFirstThrowableProxyConverterTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/RootCauseFirstThrowableProxyConverterTest.java @@ -20,7 +20,10 @@ import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.PatternLayout; import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.classic.spi.StackTraceElementProxy; +import ch.qos.logback.core.CoreConstants; import org.junit.Before; import org.junit.Test; @@ -35,6 +38,8 @@ //import static org.fest.assertions.Assertions.assertThat; import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** * @author Tomasz Nurkiewicz @@ -124,4 +129,48 @@ public void nested() { assertTrue(positionOf("nesting level =1").in(result) < positionOf("nesting level =2").in(result)); } + @Test + public void suppressedExceptionFollowsItsParentWithPrefix() { + //given + context.setPackagingDataEnabled(false); + StackTraceElement outerFrame = new StackTraceElement("a.A", "m", "A.java", 1); + StackTraceElement suppressedFrame = new StackTraceElement("s.S", "m", "S.java", 5); + Exception outer = new Exception("outer"); + outer.setStackTrace(new StackTraceElement[] {outerFrame}); + Exception suppressed = new Exception("suppressed"); + suppressed.setStackTrace(new StackTraceElement[] {suppressedFrame, outerFrame}); + outer.addSuppressed(suppressed); + + //when + String result = converter.convert(createLoggingEvent(outer)); + + //then + String ls = CoreConstants.LINE_SEPARATOR; + assertEquals("java.lang.Exception: outer" + ls + + "\tat " + outerFrame + ls + + "\tSuppressed: java.lang.Exception: suppressed" + ls + + "\t\tat " + suppressedFrame + ls + + "\t\t... 1 common frames omitted" + ls, result); + } + + @Test + public void proxyWithoutSuppressedArrayIsPrinted() { + //given + StackTraceElement frame = new StackTraceElement("a.A", "m", "A.java", 1); + IThrowableProxy tp = mock(IThrowableProxy.class); + when(tp.getClassName()).thenReturn("x.Boom"); + when(tp.getMessage()).thenReturn("msg"); + when(tp.getStackTraceElementProxyArray()) + .thenReturn(new StackTraceElementProxy[] {new StackTraceElementProxy(frame)}); + when(tp.getSuppressed()).thenReturn(null); + ILoggingEvent event = mock(ILoggingEvent.class); + when(event.getThrowableProxy()).thenReturn(tp); + + //when + String result = converter.convert(event); + + //then + String ls = CoreConstants.LINE_SEPARATOR; + assertEquals("x.Boom: msg" + ls + "\tat " + frame + ls, result); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/StubEventEvaluator.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/StubEventEvaluator.java new file mode 100644 index 000000000..c5a81e437 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/StubEventEvaluator.java @@ -0,0 +1,71 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.pattern; + +import java.util.Map; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.Context; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.boolex.EvaluationException; +import ch.qos.logback.core.boolex.EventEvaluator; +import ch.qos.logback.core.boolex.EventEvaluatorBase; + +/** + * Evaluator for the converter tests of this package: it returns a fixed result + * (or throws a fixed exception) and counts its invocations. + */ +class StubEventEvaluator extends EventEvaluatorBase { + + private final boolean result; + private final EvaluationException failure; + int invocations; + + StubEventEvaluator(String name, boolean result) { + this(name, result, null); + } + + StubEventEvaluator(String name, EvaluationException failure) { + this(name, false, failure); + } + + private StubEventEvaluator(String name, boolean result, EvaluationException failure) { + setName(name); + this.result = result; + this.failure = failure; + } + + /** + * Makes this evaluator known under its name in the context's evaluator map, + * where converters look up the evaluators named in their options. + */ + @SuppressWarnings("unchecked") + StubEventEvaluator registerIn(Context context) { + Map> evaluatorMap = + (Map>) context.getObject(CoreConstants.EVALUATOR_MAP); + evaluatorMap.put(getName(), this); + return this; + } + + @Override + public boolean evaluate(ILoggingEvent event) throws EvaluationException { + invocations++; + if (failure != null) { + throw failure; + } + return result; + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/SyslogStartConverterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/SyslogStartConverterTest.java index da0be6c47..58ef8f2a9 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/pattern/SyslogStartConverterTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/SyslogStartConverterTest.java @@ -19,18 +19,28 @@ import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.core.status.Status; import org.junit.After; import org.junit.Before; import org.junit.Ignore; import org.junit.Test; +import org.mockito.MockedConstruction; import java.net.InetAddress; import java.net.UnknownHostException; +import java.text.DateFormatSymbols; +import java.text.SimpleDateFormat; import java.util.Arrays; import java.util.Calendar; +import java.util.List; import java.util.Locale; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mockConstruction; public class SyslogStartConverterTest { @@ -120,6 +130,39 @@ public void hostnameShouldNotIncludeDomain() throws Exception { assertEquals("<191>Oct 11 22:14:15 " + host + " ", converter.convert(le)); } + @Test + public void missingFacilityOptionIsReportedAndConverterStaysStopped() { + SyslogStartConverter c = new SyslogStartConverter(); + c.setContext(lc); + c.start(); + + assertFalse(c.isStarted()); + List statuses = lc.getStatusManager().getCopyOfStatusList(); + assertEquals(1, statuses.size()); + assertEquals(Status.ERROR, statuses.get(0).getLevel()); + assertEquals("was expecting a facility string as an option", statuses.get(0).getMessage()); + } + + @Test + public void dateFormatSetupFailureIsReportedAndConverterStaysStopped() { + IllegalArgumentException failure = new IllegalArgumentException("injected"); + SyslogStartConverter c = new SyslogStartConverter(); + c.setContext(lc); + c.setOptionList(Arrays.asList("local7")); + + try (MockedConstruction ignored = mockConstruction(SimpleDateFormat.class, + (mock, context) -> doThrow(failure).when(mock).setDateFormatSymbols(any(DateFormatSymbols.class)))) { + c.start(); + } + + assertFalse(c.isStarted()); + List statuses = lc.getStatusManager().getCopyOfStatusList(); + assertEquals(1, statuses.size()); + assertEquals(Status.ERROR, statuses.get(0).getLevel()); + assertEquals("Could not instantiate SimpleDateFormat", statuses.get(0).getMessage()); + assertSame(failure, statuses.get(0).getThrowable()); + } + private LoggingEvent createLoggingEvent() { return new LoggingEvent(this.getClass().getName(), lc .getLogger(Logger.ROOT_LOGGER_NAME), Level.DEBUG, "test message", null, diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/TargetLengthBasedClassNameAbbreviatorTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/TargetLengthBasedClassNameAbbreviatorTest.java index a2aa53430..cabe3ffaa 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/pattern/TargetLengthBasedClassNameAbbreviatorTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/TargetLengthBasedClassNameAbbreviatorTest.java @@ -17,6 +17,10 @@ import static junit.framework.Assert.*; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.io.UnsupportedEncodingException; + import org.junit.Test; import ch.qos.logback.classic.pattern.TargetLengthBasedClassNameAbbreviator; @@ -148,4 +152,30 @@ public void testXDot() { assertEquals("c.l.w.alligator.tomato.Foobar", abbreviator.abbreviate(name)); } } + + @Test + public void nullClassNameIsRejected() { + TargetLengthBasedClassNameAbbreviator abbreviator = new TargetLengthBasedClassNameAbbreviator(10); + try { + abbreviator.abbreviate(null); + fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertEquals("Class name may not be null", e.getMessage()); + } + } + + @Test + public void printArrayWritesCommaSeparatedValuesToStandardOut() throws UnsupportedEncodingException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + PrintStream original = System.out; + System.setOut(new PrintStream(bytes, true, "UTF-8")); + try { + TargetLengthBasedClassNameAbbreviator.printArray("dots: ", new int[] {3, 11, 15}); + TargetLengthBasedClassNameAbbreviator.printArray("none: ", new int[0]); + } finally { + System.setOut(original); + } + String ls = System.getProperty("line.separator"); + assertEquals("dots: 3, 11, 15" + ls + "none: " + ls, bytes.toString("UTF-8")); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/ThrowableHandlingConverterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/ThrowableHandlingConverterTest.java new file mode 100644 index 000000000..8bc7e3f4f --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/ThrowableHandlingConverterTest.java @@ -0,0 +1,44 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.pattern; + +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import ch.qos.logback.classic.spi.ILoggingEvent; + +public class ThrowableHandlingConverterTest { + + @Test + public void customThrowableHandlingConverterHandlesThrowables() { + ThrowableHandlingConverter converter = new ThrowableHandlingConverter() { + @Override + public String convert(ILoggingEvent event) { + return ""; + } + }; + assertTrue(converter.handlesThrowable()); + } + + @Test + public void builtInThrowableConvertersHandleThrowables() { + assertTrue(new ThrowableProxyConverter().handlesThrowable()); + assertTrue(new ExtendedThrowableProxyConverter().handlesThrowable()); + assertTrue(new RootCauseFirstThrowableProxyConverter().handlesThrowable()); + assertTrue(new NopThrowableInformationConverter().handlesThrowable()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/ThrowableProxyConverterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/ThrowableProxyConverterTest.java index 6f3035b7a..2a2f75cd8 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/pattern/ThrowableProxyConverterTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/ThrowableProxyConverterTest.java @@ -34,9 +34,14 @@ import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.classic.spi.StackTraceElementProxy; import ch.qos.logback.classic.util.TestHelper; import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.boolex.EvaluationException; +import ch.qos.logback.core.status.ErrorStatus; +import ch.qos.logback.core.status.Status; import static ch.qos.logback.classic.util.TestHelper.addSuppressed; import static org.hamcrest.Matchers.is; @@ -45,6 +50,8 @@ import static org.hamcrest.Matchers.emptyString; import static org.junit.Assert.*; import static org.junit.Assume.assumeTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class ThrowableProxyConverterTest { @@ -203,6 +210,164 @@ public void shouldLimitTotalLinesExcludingSkipped() throws Exception { assertThat(lines, Matchers.arrayWithSize(3 + 1)); } + private static final String LS = CoreConstants.LINE_SEPARATOR; + private static final StackTraceElement FRAME_A = new StackTraceElement("a.A", "m", "A.java", 1); + private static final StackTraceElement FRAME_B = new StackTraceElement("b.B", "m", "B.java", 2); + private static final StackTraceElement FRAME_C = new StackTraceElement("c.C", "m", "C.java", 3); + + /** Event carrying an exception whose stack trace is exactly FRAME_A, FRAME_B, FRAME_C. */ + private ILoggingEvent eventWithFixedStackTrace() { + Exception e = new Exception("boom"); + e.setStackTrace(new StackTraceElement[] {FRAME_A, FRAME_B, FRAME_C}); + return createLoggingEvent(e); + } + + private static final String FIXED_STACK_TRACE = "java.lang.Exception: boom" + LS + + "\tat " + FRAME_A + LS + + "\tat " + FRAME_B + LS + + "\tat " + FRAME_C + LS; + + private void restartWithOptions(String... options) { + tpc.setOptionList(Arrays.asList(options)); + tpc.start(); + } + + @Test + public void eventWithoutThrowableConvertsToEmptyString() { + assertEquals(CoreConstants.EMPTY_STRING, tpc.convert(createLoggingEvent(null))); + } + + @Test + public void unparsableLengthIsReportedAndWholeTraceIsPrinted() { + ThrowableProxyConverter converter = new ThrowableProxyConverter(); + converter.setContext(lc); + converter.setOptionList(Arrays.asList("Bogus")); + converter.start(); + + assertEquals(Integer.MAX_VALUE, converter.lengthOption); + List statuses = lc.getStatusManager().getCopyOfStatusList(); + assertEquals(1, statuses.size()); + assertEquals(Status.ERROR, statuses.get(0).getLevel()); + assertEquals("Could not parse [bogus] as an integer", statuses.get(0).getMessage()); + assertEquals(FIXED_STACK_TRACE, converter.convert(eventWithFixedStackTrace())); + } + + @Test + public void optionsNameEitherEvaluatorsOrIgnoredStackTraceLines() { + StubEventEvaluator e1 = new StubEventEvaluator("e1", false).registerIn(lc); + StubEventEvaluator e2 = new StubEventEvaluator("e2", false).registerIn(lc); + + restartWithOptions("full", "e1", "skipA", "e2", "skipB"); + + assertEquals(2, tpc.evaluatorList.size()); + assertSame(e1, tpc.evaluatorList.get(0)); + assertSame(e2, tpc.evaluatorList.get(1)); + assertEquals(Arrays.asList("skipA", "skipB"), tpc.ignoredStackTraceLines); + } + + @Test + public void matchingEvaluatorSuppressesStackTrace() { + StubEventEvaluator noMatch = new StubEventEvaluator("noMatch", false).registerIn(lc); + StubEventEvaluator match = new StubEventEvaluator("match", true).registerIn(lc); + StubEventEvaluator notReached = new StubEventEvaluator("notReached", true).registerIn(lc); + restartWithOptions("full", "noMatch", "match", "notReached"); + + assertEquals(CoreConstants.EMPTY_STRING, tpc.convert(eventWithFixedStackTrace())); + assertEquals(1, noMatch.invocations); + assertEquals(1, match.invocations); + assertEquals("evaluation stops at the first match", 0, notReached.invocations); + } + + @Test + public void nonMatchingEvaluatorsLetStackTraceThrough() { + StubEventEvaluator e1 = new StubEventEvaluator("e1", false).registerIn(lc); + StubEventEvaluator e2 = new StubEventEvaluator("e2", false).registerIn(lc); + restartWithOptions("full", "e1", "e2"); + + assertEquals(FIXED_STACK_TRACE, tpc.convert(eventWithFixedStackTrace())); + assertEquals(1, e1.invocations); + assertEquals(1, e2.invocations); + } + + @Test + public void stopDiscardsEvaluators() { + new StubEventEvaluator("match", true).registerIn(lc); + restartWithOptions("full", "match"); + assertEquals(CoreConstants.EMPTY_STRING, tpc.convert(eventWithFixedStackTrace())); + + tpc.stop(); + + assertFalse(tpc.isStarted()); + assertNull(tpc.evaluatorList); + assertEquals(FIXED_STACK_TRACE, tpc.convert(eventWithFixedStackTrace())); + } + + @Test + public void evaluatorFailuresAreReportedUntilTheErrorLimit() { + EvaluationException failure = new EvaluationException("boom"); + new StubEventEvaluator("failing", failure).registerIn(lc); + restartWithOptions("full", "failing"); + + for (int i = 0; i < CoreConstants.MAX_ERROR_COUNT + 1; i++) { + // a failing evaluator does not suppress the stack trace + assertEquals(FIXED_STACK_TRACE, tpc.convert(eventWithFixedStackTrace())); + } + + assertEquals(CoreConstants.MAX_ERROR_COUNT + 1, tpc.errorCount); + List statuses = lc.getStatusManager().getCopyOfStatusList(); + assertEquals(statuses.toString(), CoreConstants.MAX_ERROR_COUNT, statuses.size()); + for (int i = 0; i < CoreConstants.MAX_ERROR_COUNT - 1; i++) { + Status status = statuses.get(i); + assertEquals(Status.ERROR, status.getLevel()); + assertEquals("Exception thrown for evaluator named [failing]", status.getMessage()); + assertSame(failure, status.getThrowable()); + assertSame(tpc, status.getOrigin()); + } + Status last = statuses.get(CoreConstants.MAX_ERROR_COUNT - 1); + assertTrue(last instanceof ErrorStatus); + assertEquals("Exception thrown for evaluator named [failing].", last.getMessage()); + assertSame(failure, last.getThrowable()); + assertSame(tpc, last.getOrigin()); + assertTrue(last.hasChildren()); + Status child = last.iterator().next(); + assertEquals(Status.ERROR, child.getLevel()); + assertEquals("This was the last warning about this evaluator's errors. " + + "We don't want the StatusManager to get flooded.", child.getMessage()); + assertSame(tpc, child.getOrigin()); + } + + @Test + public void skippedFramesAreCountedOnTheNextPrintedFrame() { + restartWithOptions("full", "b.B"); + + assertEquals("java.lang.Exception: boom" + LS + + "\tat " + FRAME_A + LS + + "\tat " + FRAME_C + " [1 skipped]" + LS, tpc.convert(eventWithFixedStackTrace())); + } + + @Test + public void trailingSkippedFramesAreCountedOnTheirOwnLine() { + restartWithOptions("full", "b.B", "c.C"); + + assertEquals("java.lang.Exception: boom" + LS + + "\tat " + FRAME_A + LS + + " [2 skipped]" + LS, tpc.convert(eventWithFixedStackTrace())); + } + + @Test + public void proxyWithoutSuppressedArrayIsPrinted() { + IThrowableProxy tp = mock(IThrowableProxy.class); + when(tp.getClassName()).thenReturn("x.Boom"); + when(tp.getMessage()).thenReturn("msg"); + when(tp.getStackTraceElementProxyArray()) + .thenReturn(new StackTraceElementProxy[] {new StackTraceElementProxy(FRAME_A)}); + when(tp.getSuppressed()).thenReturn(null); + ILoggingEvent event = mock(ILoggingEvent.class); + when(event.getThrowableProxy()).thenReturn(tp); + + assertEquals("x.Boom: msg" + LS + "\tat " + FRAME_A + LS, tpc.convert(event)); + } + void someMethod() throws Exception { throw new Exception("someMethod"); } From aa381a2e4a96547e0ce0005b7de4728e2c961cc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:32:26 +0000 Subject: [PATCH 47/79] test: pin common-frame elision and root-cause-first suppressed prefix Kill two surviving mutants in classic.pattern: the length-vs-stack-depth boundary in ThrowableProxyConverter.subjoinSTEPArray, and the prefix reset in RootCauseFirstThrowableProxyConverter for a suppressed exception that has its own cause. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- ...CauseFirstThrowableProxyConverterTest.java | 30 +++++++++++++++++++ .../pattern/ThrowableProxyConverterTest.java | 24 +++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/RootCauseFirstThrowableProxyConverterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/RootCauseFirstThrowableProxyConverterTest.java index cc1bbcdc2..a2a20ad61 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/pattern/RootCauseFirstThrowableProxyConverterTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/RootCauseFirstThrowableProxyConverterTest.java @@ -153,6 +153,36 @@ public void suppressedExceptionFollowsItsParentWithPrefix() { + "\t\t... 1 common frames omitted" + ls, result); } + @Test + public void suppressedPrefixIsPrintedOnlyOnTheRootCauseOfTheSuppressedException() { + //given + context.setPackagingDataEnabled(false); + StackTraceElement outerFrame = new StackTraceElement("a.A", "m", "A.java", 1); + StackTraceElement suppressedFrame = new StackTraceElement("s.S", "m", "S.java", 5); + StackTraceElement causeFrame = new StackTraceElement("c.C", "m", "C.java", 9); + Exception outer = new Exception("outer"); + outer.setStackTrace(new StackTraceElement[] {outerFrame}); + Exception cause = new Exception("cause"); + cause.setStackTrace(new StackTraceElement[] {causeFrame, suppressedFrame, outerFrame}); + Exception suppressed = new Exception("suppressed", cause); + suppressed.setStackTrace(new StackTraceElement[] {suppressedFrame, outerFrame}); + outer.addSuppressed(suppressed); + + //when + String result = converter.convert(createLoggingEvent(outer)); + + //then + String ls = CoreConstants.LINE_SEPARATOR; + assertEquals("java.lang.Exception: outer" + ls + + "\tat " + outerFrame + ls + + "\tSuppressed: java.lang.Exception: cause" + ls + + "\t\tat " + causeFrame + ls + + "\t\t... 2 common frames omitted" + ls + + "\tWrapped by: java.lang.Exception: suppressed" + ls + + "\t\tat " + suppressedFrame + ls + + "\t\t... 1 common frames omitted" + ls, result); + } + @Test public void proxyWithoutSuppressedArrayIsPrinted() { //given diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/ThrowableProxyConverterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/ThrowableProxyConverterTest.java index 2a2f75cd8..e2cb13cb7 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/pattern/ThrowableProxyConverterTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/ThrowableProxyConverterTest.java @@ -354,6 +354,30 @@ public void trailingSkippedFramesAreCountedOnTheirOwnLine() { + " [2 skipped]" + LS, tpc.convert(eventWithFixedStackTrace())); } + @Test + public void commonFramesAreOmittedOnlyWhenLengthExceedsStackDepth() { + Exception cause = new Exception("cause"); + cause.setStackTrace(new StackTraceElement[] {FRAME_C, FRAME_A, FRAME_B}); + Exception outer = new Exception("outer", cause); + outer.setStackTrace(new StackTraceElement[] {FRAME_A, FRAME_B}); + String outerLines = "java.lang.Exception: outer" + LS + + "\tat " + FRAME_A + LS + + "\tat " + FRAME_B + LS + + "Caused by: java.lang.Exception: cause" + LS; + + restartWithOptions("4"); + assertEquals(outerLines + + "\tat " + FRAME_C + LS + + "\t... 2 common frames omitted" + LS, tpc.convert(createLoggingEvent(outer))); + + // a length equal to the cause's depth restricts printing, which lists the common frames + restartWithOptions("3"); + assertEquals(outerLines + + "\tat " + FRAME_C + LS + + "\tat " + FRAME_A + LS + + "\tat " + FRAME_B + LS, tpc.convert(createLoggingEvent(outer))); + } + @Test public void proxyWithoutSuppressedArrayIsPrinted() { IThrowableProxy tp = mock(IThrowableProxy.class); From 73f4b1da71a6f16b46357920a7cda9320145623e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:14:36 +0000 Subject: [PATCH 48/79] test: cover Level, LoggerContext, filters, evaluators, layouts and misc classic classes Close the remaining JaCoCo line and branch gaps in AsyncAppender, ClassicConstants, Level, LoggerContext, OnErrorEvaluator, OnMarkerEvaluator, SQLBuilder, SimpleDBNameResolver, LevelFilter, ThresholdFilter, TTLLLayout, log4j XMLLayout, HighlightingCompositeConverter and DefaultContextSelector. The tests pin: which levels AsyncAppender discards once the queue is below the discarding threshold; every Level conversion (string, int, Integer, SLF4J LocationAwareLogger ints), its error messages and flyweight deserialization; LoggerContext.putProperties refreshing the remote view, one- and two-argument log calls consulting turbo filters with their arguments, listener removal/copying and the caller data depth limit; filter and evaluator decisions, including when not started; TTLL and XML layout output with and without throwable, caller data, MDC properties, and the XML buffer reset after an oversized event; ANSI colors per level from %highlight; and DefaultContextSelector lookups. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../logback/classic/AsyncAppenderTest.java | 39 ++++ .../logback/classic/ClassicConstantsTest.java | 39 ++++ .../ch/qos/logback/classic/LevelTest.java | 140 +++++++++++++++ .../logback/classic/LoggerContextTest.java | 167 ++++++++++++++++++ .../classic/boolex/OnErrorEvaluatorTest.java | 55 ++++++ .../classic/boolex/OnMarkerEvaluatorTest.java | 41 ++++- .../logback/classic/db/SQLBuilderTest.java | 48 +++++ .../db/names/SimpleDBNameResolverTest.java | 65 +++++++ .../classic/filter/LevelFilterTest.java | 76 ++++++++ .../classic/filter/ThresholdFilterTest.java | 82 +++++++++ .../classic/layout/TTLLLayoutTest.java | 30 ++++ .../logback/classic/log4j/XMLLayoutTest.java | 137 ++++++++++++++ .../HighlightingCompositeConverterTest.java | 68 +++++++ .../selector/DefaultContextSelectorTest.java | 65 +++++++ 14 files changed, 1050 insertions(+), 2 deletions(-) create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/ClassicConstantsTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/boolex/OnErrorEvaluatorTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/db/SQLBuilderTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/db/names/SimpleDBNameResolverTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/filter/LevelFilterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/filter/ThresholdFilterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/pattern/color/HighlightingCompositeConverterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/selector/DefaultContextSelectorTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/classic/AsyncAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/classic/AsyncAppenderTest.java index 8f2beff43..c8044e567 100755 --- a/logback-android/src/test/java/ch/qos/logback/classic/AsyncAppenderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/AsyncAppenderTest.java @@ -17,6 +17,7 @@ import ch.qos.logback.classic.net.testObjectBuilders.LoggingEventBuilderInContext; import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggingEvent; import ch.qos.logback.core.UnsynchronizedAppenderBase; import ch.qos.logback.core.read.ListAppender; import ch.qos.logback.core.testUtil.RandomUtil; @@ -86,4 +87,42 @@ public void settingIncludeCallerDataPropertyCausedCallerDataToBeIncluded() { StackTraceElement ste = e.getCallerData()[0]; assertEquals(thisClassName, ste.getClassName()); } + + private ILoggingEvent eventAt(Level level) { + return new LoggingEvent(thisClassName, context.getLogger(thisClassName), level, level.toString(), null, null); + } + + @Test + public void eventsOfLevelInfoOrLowerAreDiscardable() { + assertTrue(asyncAppender.isDiscardable(eventAt(Level.TRACE))); + assertTrue(asyncAppender.isDiscardable(eventAt(Level.DEBUG))); + assertTrue(asyncAppender.isDiscardable(eventAt(Level.INFO))); + assertFalse(asyncAppender.isDiscardable(eventAt(Level.WARN))); + assertFalse(asyncAppender.isDiscardable(eventAt(Level.ERROR))); + } + + @Test + public void discardableEventsAreDroppedWhenQueueIsBelowDiscardingThreshold() { + asyncAppender.addAppender(listAppender); + asyncAppender.setQueueSize(10); + // remaining capacity (at most 10) is always below this threshold + asyncAppender.setDiscardingThreshold(11); + asyncAppender.start(); + + for (Level level : new Level[] {Level.TRACE, Level.DEBUG, Level.INFO, Level.WARN, Level.ERROR}) { + asyncAppender.doAppend(eventAt(level)); + } + asyncAppender.stop(); + + assertEquals(2, listAppender.list.size()); + assertEquals(Level.WARN, listAppender.list.get(0).getLevel()); + assertEquals(Level.ERROR, listAppender.list.get(1).getLevel()); + } + + @Test + public void includeCallerDataIsOffByDefaultAndSettable() { + assertFalse(asyncAppender.isIncludeCallerData()); + asyncAppender.setIncludeCallerData(true); + assertTrue(asyncAppender.isIncludeCallerData()); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/ClassicConstantsTest.java b/logback-android/src/test/java/ch/qos/logback/classic/ClassicConstantsTest.java new file mode 100644 index 000000000..7eb2df7ac --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/ClassicConstantsTest.java @@ -0,0 +1,39 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; + +import org.junit.Test; +import org.slf4j.MarkerFactory; + +public class ClassicConstantsTest { + + @Test + public void finalizeSessionMarkerIsTheSharedMarkerNamedFinalizeSession() { + assertEquals("FINALIZE_SESSION", ClassicConstants.FINALIZE_SESSION); + assertEquals(ClassicConstants.FINALIZE_SESSION, ClassicConstants.FINALIZE_SESSION_MARKER.getName()); + assertSame(MarkerFactory.getMarker(ClassicConstants.FINALIZE_SESSION), ClassicConstants.FINALIZE_SESSION_MARKER); + } + + @Test + public void publicConstructorIsAvailable() { + // the class exposes an implicit public constructor (kept for API compatibility) + assertNotNull(new ClassicConstants()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/LevelTest.java b/logback-android/src/test/java/ch/qos/logback/classic/LevelTest.java index 551dcc3aa..5427e8a76 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/LevelTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/LevelTest.java @@ -16,8 +16,19 @@ package ch.qos.logback.classic; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.lang.reflect.Constructor; import org.junit.Test; +import org.slf4j.spi.LocationAwareLogger; public class LevelTest { @@ -44,4 +55,133 @@ public void withSpaceSuffix( ) { public void withSpaceAround( ) { assertEquals(Level.INFO, Level.toLevel(" INFO ")); } + + @Test + public void toLevelStringAcceptsAllAndOffIgnoringCase() { + assertSame(Level.ALL, Level.toLevel("all")); + assertSame(Level.OFF, Level.toLevel("Off")); + assertSame(Level.TRACE, Level.toLevel("trace", Level.ERROR)); + } + + @Test + public void toLevelStringReturnsDefaultForNullOrUnknownInput() { + assertSame(Level.WARN, Level.toLevel((String) null, Level.WARN)); + assertSame(Level.ERROR, Level.toLevel("VERBOSE", Level.ERROR)); + assertSame(Level.DEBUG, Level.toLevel("VERBOSE")); + assertSame(Level.DEBUG, Level.toLevel((String) null)); + } + + @Test + public void valueOfFollowsToLevelWithDebugAsDefault() { + assertSame(Level.ERROR, Level.valueOf("error")); + assertSame(Level.DEBUG, Level.valueOf("unknown")); + } + + @Test + public void toLevelIntMapsEveryLevelIntToItsLevel() { + assertSame(Level.ALL, Level.toLevel(Level.ALL_INT)); + assertSame(Level.TRACE, Level.toLevel(Level.TRACE_INT)); + assertSame(Level.DEBUG, Level.toLevel(Level.DEBUG_INT, Level.OFF)); + assertSame(Level.INFO, Level.toLevel(Level.INFO_INT)); + assertSame(Level.WARN, Level.toLevel(Level.WARN_INT)); + assertSame(Level.ERROR, Level.toLevel(Level.ERROR_INT)); + assertSame(Level.OFF, Level.toLevel(Level.OFF_INT)); + } + + @Test + public void toLevelIntReturnsDefaultForUnknownInt() { + assertSame(Level.INFO, Level.toLevel(12345, Level.INFO)); + assertSame(Level.DEBUG, Level.toLevel(12345)); + } + + @Test + public void toIntegerReturnsTheCachedIntegerOfEachLevel() { + assertSame(Level.ALL_INTEGER, Level.ALL.toInteger()); + assertSame(Level.TRACE_INTEGER, Level.TRACE.toInteger()); + assertSame(Level.DEBUG_INTEGER, Level.DEBUG.toInteger()); + assertSame(Level.INFO_INTEGER, Level.INFO.toInteger()); + assertSame(Level.WARN_INTEGER, Level.WARN.toInteger()); + assertSame(Level.ERROR_INTEGER, Level.ERROR.toInteger()); + assertSame(Level.OFF_INTEGER, Level.OFF.toInteger()); + assertEquals(Integer.valueOf(Level.WARN_INT), Level.WARN.toInteger()); + } + + @Test + public void toIntegerRejectsALevelWithAnUnknownInt() throws Exception { + Constructor constructor = Level.class.getDeclaredConstructor(int.class, String.class); + constructor.setAccessible(true); + final Level bogus = constructor.newInstance(12345, "BOGUS"); + + IllegalStateException e = assertThrows(IllegalStateException.class, bogus::toInteger); + assertEquals("Level BOGUS, 12345 is unknown.", e.getMessage()); + } + + @Test + public void toStringAndToIntExposeNameAndValue() { + assertEquals("WARN", Level.WARN.toString()); + assertEquals(Level.WARN_INT, Level.WARN.toInt()); + } + + @Test + public void isGreaterOrEqualComparesLevelInts() { + assertTrue(Level.WARN.isGreaterOrEqual(Level.INFO)); + assertTrue(Level.WARN.isGreaterOrEqual(Level.WARN)); + assertFalse(Level.INFO.isGreaterOrEqual(Level.WARN)); + } + + @Test + public void fromLocationAwareLoggerIntegerMapsEverySlf4jLevel() { + assertSame(Level.TRACE, Level.fromLocationAwareLoggerInteger(LocationAwareLogger.TRACE_INT)); + assertSame(Level.DEBUG, Level.fromLocationAwareLoggerInteger(LocationAwareLogger.DEBUG_INT)); + assertSame(Level.INFO, Level.fromLocationAwareLoggerInteger(LocationAwareLogger.INFO_INT)); + assertSame(Level.WARN, Level.fromLocationAwareLoggerInteger(LocationAwareLogger.WARN_INT)); + assertSame(Level.ERROR, Level.fromLocationAwareLoggerInteger(LocationAwareLogger.ERROR_INT)); + } + + @Test + public void fromLocationAwareLoggerIntegerRejectsUnknownValue() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> Level.fromLocationAwareLoggerInteger(7)); + assertEquals("7 not a valid level value", e.getMessage()); + } + + @Test + public void toLocationAwareLoggerIntegerMapsEveryLoggableLevel() { + assertEquals(LocationAwareLogger.TRACE_INT, Level.toLocationAwareLoggerInteger(Level.TRACE)); + assertEquals(LocationAwareLogger.DEBUG_INT, Level.toLocationAwareLoggerInteger(Level.DEBUG)); + assertEquals(LocationAwareLogger.INFO_INT, Level.toLocationAwareLoggerInteger(Level.INFO)); + assertEquals(LocationAwareLogger.WARN_INT, Level.toLocationAwareLoggerInteger(Level.WARN)); + assertEquals(LocationAwareLogger.ERROR_INT, Level.toLocationAwareLoggerInteger(Level.ERROR)); + } + + @Test + public void toLocationAwareLoggerIntegerRejectsNull() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> Level.toLocationAwareLoggerInteger(null)); + assertEquals("null level parameter is not admitted", e.getMessage()); + } + + @Test + public void toLocationAwareLoggerIntegerRejectsLevelsWithoutSlf4jCounterpart() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> Level.toLocationAwareLoggerInteger(Level.OFF)); + assertEquals("OFF not a valid level value", e.getMessage()); + e = assertThrows(IllegalArgumentException.class, + () -> Level.toLocationAwareLoggerInteger(Level.ALL)); + assertEquals("ALL not a valid level value", e.getMessage()); + } + + @Test + public void deserializationResolvesToTheFlyweightInstance() throws Exception { + for (Level level : new Level[] {Level.ALL, Level.TRACE, Level.INFO, Level.OFF}) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + oos.writeObject(level); + oos.close(); + ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray())); + Object back = ois.readObject(); + ois.close(); + assertSame(level, back); + } + } } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/LoggerContextTest.java b/logback-android/src/test/java/ch/qos/logback/classic/LoggerContextTest.java index e847321fc..5daf4db83 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/LoggerContextTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/LoggerContextTest.java @@ -18,16 +18,29 @@ import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertNotSame; import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertTrue; import static junit.framework.Assert.fail; +import static org.junit.Assert.assertArrayEquals; import org.junit.Before; import org.junit.Test; +import java.util.List; import java.util.Map; +import java.util.Properties; +import org.slf4j.Marker; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggerContextListener; +import ch.qos.logback.classic.spi.LoggerContextVO; +import ch.qos.logback.classic.spi.LoggingEvent; import ch.qos.logback.classic.turbo.NOPTurboFilter; +import ch.qos.logback.classic.turbo.TurboFilter; +import ch.qos.logback.core.read.ListAppender; +import ch.qos.logback.core.spi.FilterReply; import ch.qos.logback.core.CoreConstants; import ch.qos.logback.core.rolling.helper.FileNamePattern; import ch.qos.logback.core.status.StatusManager; @@ -282,4 +295,158 @@ public void run() { } + @Test + public void putPropertiesAddsEveryPropertyAndRefreshesRemoteView() { + LoggerContextVO before = lc.getLoggerContextRemoteView(); + Properties props = new Properties(); + props.setProperty("k1", "v1"); + props.setProperty("k2", "v2"); + + lc.putProperties(props); + + assertEquals("v1", lc.getProperty("k1")); + assertEquals("v2", lc.getProperty("k2")); + LoggerContextVO after = lc.getLoggerContextRemoteView(); + assertNotSame(before, after); + assertEquals("v1", after.getPropertyMap().get("k1")); + assertEquals("v2", after.getPropertyMap().get("k2")); + assertNull(before.getPropertyMap().get("k1")); + } + + @Test + public void oneArgumentLogCallIsDecidedByTurboFiltersWithThatArgument() { + RecordingTurboFilter tf = addRecordingTurboFilter(FilterReply.ACCEPT); + ListAppender la = attachListAppender(); + Logger logger = lc.getLogger("one"); + logger.setLevel(Level.ERROR); + + logger.debug("a {}", "p1"); + + assertEquals("a {}", tf.format); + assertEquals(Level.DEBUG, tf.level); + assertArrayEquals(new Object[] {"p1"}, tf.params); + // ACCEPT overrides the logger level + assertEquals(1, la.list.size()); + assertEquals("a p1", la.list.get(0).getFormattedMessage()); + + tf.reply = FilterReply.DENY; + logger.error("b {}", "p2"); + assertArrayEquals(new Object[] {"p2"}, tf.params); + assertEquals(1, la.list.size()); + } + + @Test + public void twoArgumentLogCallIsDecidedByTurboFiltersWithBothArguments() { + RecordingTurboFilter tf = addRecordingTurboFilter(FilterReply.ACCEPT); + ListAppender la = attachListAppender(); + Logger logger = lc.getLogger("two"); + logger.setLevel(Level.ERROR); + + logger.info("a {} {}", "p1", "p2"); + + assertEquals("a {} {}", tf.format); + assertEquals(Level.INFO, tf.level); + assertArrayEquals(new Object[] {"p1", "p2"}, tf.params); + assertEquals(1, la.list.size()); + assertEquals("a p1 p2", la.list.get(0).getFormattedMessage()); + + tf.reply = FilterReply.DENY; + logger.error("b {} {}", "p3", "p4"); + assertArrayEquals(new Object[] {"p3", "p4"}, tf.params); + assertEquals(1, la.list.size()); + } + + @Test + public void removedListenerIsNoLongerNotified() { + RecordingListener kept = new RecordingListener(); + RecordingListener removed = new RecordingListener(); + lc.addListener(kept); + lc.addListener(removed); + assertEquals(2, lc.getCopyOfListenerList().size()); + + lc.removeListener(removed); + lc.start(); + + List listeners = lc.getCopyOfListenerList(); + assertEquals(1, listeners.size()); + assertTrue(listeners.contains(kept)); + assertEquals(1, kept.startCount); + assertEquals(0, removed.startCount); + } + + @Test + public void copyOfListenerListIsDetachedFromTheContext() { + RecordingListener listener = new RecordingListener(); + lc.addListener(listener); + + List copy = lc.getCopyOfListenerList(); + copy.clear(); + + assertEquals(1, lc.getCopyOfListenerList().size()); + lc.start(); + assertEquals(1, listener.startCount); + } + + @Test + public void maxCallerDataDepthLimitsExtractedCallerData() { + assertEquals(ClassicConstants.DEFAULT_MAX_CALLEDER_DATA_DEPTH, lc.getMaxCallerDataDepth()); + lc.setMaxCallerDataDepth(3); + assertEquals(3, lc.getMaxCallerDataDepth()); + + LoggingEvent event = new LoggingEvent(LoggerContextTest.class.getName(), lc.getLogger("x"), Level.INFO, "m", null, null); + // the caller is the frame right below this test class, and the stack below it is deeper than 3 + assertEquals(3, event.getCallerData().length); + } + + private RecordingTurboFilter addRecordingTurboFilter(FilterReply reply) { + RecordingTurboFilter tf = new RecordingTurboFilter(); + tf.reply = reply; + tf.start(); + lc.addTurboFilter(tf); + return tf; + } + + private ListAppender attachListAppender() { + ListAppender la = new ListAppender(); + la.setContext(lc); + la.start(); + lc.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(la); + return la; + } + + static class RecordingTurboFilter extends TurboFilter { + FilterReply reply; + String format; + Level level; + Object[] params; + + @Override + public FilterReply decide(List markers, Logger logger, Level level, String format, Object[] params, Throwable t) { + this.level = level; + this.format = format; + this.params = params; + return reply; + } + } + + static class RecordingListener implements LoggerContextListener { + int startCount; + + public boolean isResetResistant() { + return false; + } + + public void onStart(LoggerContext context) { + startCount++; + } + + public void onReset(LoggerContext context) { + } + + public void onStop(LoggerContext context) { + } + + public void onLevelChange(Logger logger, Level level) { + } + } } \ No newline at end of file diff --git a/logback-android/src/test/java/ch/qos/logback/classic/boolex/OnErrorEvaluatorTest.java b/logback-android/src/test/java/ch/qos/logback/classic/boolex/OnErrorEvaluatorTest.java new file mode 100644 index 000000000..493595901 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/boolex/OnErrorEvaluatorTest.java @@ -0,0 +1,55 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.boolex; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.core.boolex.EvaluationException; + +public class OnErrorEvaluatorTest { + + LoggerContext lc = new LoggerContext(); + OnErrorEvaluator evaluator = new OnErrorEvaluator(); + + @Before + public void before() { + evaluator.setContext(lc); + evaluator.start(); + } + + LoggingEvent makeEvent(Level level) { + return new LoggingEvent("x", lc.getLogger("x"), level, "msg", null, null); + } + + @Test + public void errorEventsEvaluateToTrue() throws EvaluationException { + assertTrue(evaluator.evaluate(makeEvent(Level.ERROR))); + } + + @Test + public void eventsBelowErrorEvaluateToFalse() throws EvaluationException { + assertFalse(evaluator.evaluate(makeEvent(Level.WARN))); + assertFalse(evaluator.evaluate(makeEvent(Level.INFO))); + assertFalse(evaluator.evaluate(makeEvent(Level.TRACE))); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/boolex/OnMarkerEvaluatorTest.java b/logback-android/src/test/java/ch/qos/logback/classic/boolex/OnMarkerEvaluatorTest.java index fce75fad5..fac8f8162 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/boolex/OnMarkerEvaluatorTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/boolex/OnMarkerEvaluatorTest.java @@ -20,8 +20,10 @@ import org.junit.Before; import org.junit.Test; +import org.slf4j.Marker; import org.slf4j.MarkerFactory; +import java.util.Arrays; import java.util.Collections; import ch.qos.logback.classic.Level; @@ -64,8 +66,43 @@ public void nullMarkerInEvaluator() throws EvaluationException { evaluator.start(); assertFalse(evaluator.evaluate(event)); } - - + + @Test + public void emptyMarkerListInEvent() throws EvaluationException { + evaluator.addMarker("M"); + evaluator.start(); + event.setMarkers(Collections.emptyList()); + assertFalse(evaluator.evaluate(event)); + } + + @Test + public void eventMarkersMatchingNoConfiguredMarker() throws EvaluationException { + evaluator.addMarker("A"); + evaluator.addMarker("B"); + evaluator.start(); + event.setMarkers(Arrays.asList(MarkerFactory.getDetachedMarker("X"), MarkerFactory.getDetachedMarker("Y"))); + assertFalse(evaluator.evaluate(event)); + } + + @Test + public void anyEventMarkerMatchingAnyConfiguredMarker() throws EvaluationException { + evaluator.addMarker("A"); + evaluator.addMarker("B"); + evaluator.start(); + event.setMarkers(Arrays.asList(MarkerFactory.getDetachedMarker("X"), MarkerFactory.getDetachedMarker("B"))); + assertTrue(evaluator.evaluate(event)); + } + + @Test + public void eventMarkerReferencingConfiguredMarker() throws EvaluationException { + evaluator.addMarker("M"); + evaluator.start(); + Marker parent = MarkerFactory.getDetachedMarker("P"); + parent.add(MarkerFactory.getDetachedMarker("M")); + event.setMarkers(Collections.singletonList(parent)); + assertTrue(evaluator.evaluate(event)); + } + LoggingEvent makeEvent() { return new LoggingEvent("x", lc.getLogger("x"), Level.DEBUG, "msg", null, null); } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/db/SQLBuilderTest.java b/logback-android/src/test/java/ch/qos/logback/classic/db/SQLBuilderTest.java new file mode 100644 index 000000000..3dee63ed2 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/db/SQLBuilderTest.java @@ -0,0 +1,48 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.db; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import org.junit.Test; + +import ch.qos.logback.classic.db.names.SimpleDBNameResolver; + +public class SQLBuilderTest { + + private final SimpleDBNameResolver resolver = new SimpleDBNameResolver(); + + @Test + public void insertPropertiesSqlUsesResolvedNames() { + resolver.setTableNamePrefix("t_"); + resolver.setColumnNameSuffix("_c"); + assertEquals("INSERT INTO t_logging_event_property (event_id_c, mapped_key_c, mapped_value_c) VALUES (?, ?, ?)", + SQLBuilder.buildInsertPropertiesSQL(resolver)); + } + + @Test + public void deleteExpiredLogsSqlComparesTimestampWithExpiry() { + assertEquals("DELETE FROM logging_event WHERE timestmp <= 1234;", + SQLBuilder.buildDeleteExpiredLogsSQL(resolver, 1234L)); + } + + @Test + public void publicConstructorIsAvailable() { + // the class exposes an implicit public constructor (kept for API compatibility) + assertNotNull(new SQLBuilder()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/db/names/SimpleDBNameResolverTest.java b/logback-android/src/test/java/ch/qos/logback/classic/db/names/SimpleDBNameResolverTest.java new file mode 100644 index 000000000..2f7ad657e --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/db/names/SimpleDBNameResolverTest.java @@ -0,0 +1,65 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.db.names; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class SimpleDBNameResolverTest { + + private final SimpleDBNameResolver resolver = new SimpleDBNameResolver(); + + @Test + public void namesAreLowerCaseEnumNamesByDefault() { + assertEquals("logging_event_exception", resolver.getTableName(TableName.LOGGING_EVENT_EXCEPTION)); + assertEquals("caller_filename", resolver.getColumnName(ColumnName.CALLER_FILENAME)); + } + + @Test + public void tableNamesGetConfiguredPrefixAndSuffix() { + resolver.setTableNamePrefix("pre_"); + resolver.setTableNameSuffix("_suf"); + assertEquals("pre_logging_event_suf", resolver.getTableName(TableName.LOGGING_EVENT)); + // table affixes do not apply to columns + assertEquals("event_id", resolver.getColumnName(ColumnName.EVENT_ID)); + } + + @Test + public void columnNamesGetConfiguredPrefixAndSuffix() { + resolver.setColumnNamePrefix("c_"); + resolver.setColumnNameSuffix("_x"); + assertEquals("c_event_id_x", resolver.getColumnName(ColumnName.EVENT_ID)); + // column affixes do not apply to tables + assertEquals("logging_event", resolver.getTableName(TableName.LOGGING_EVENT)); + } + + @Test + public void nullAffixesAreTreatedAsEmpty() { + resolver.setTableNamePrefix("pre_"); + resolver.setTableNameSuffix("_suf"); + resolver.setColumnNamePrefix("c_"); + resolver.setColumnNameSuffix("_x"); + + resolver.setTableNamePrefix(null); + resolver.setTableNameSuffix(null); + resolver.setColumnNamePrefix(null); + resolver.setColumnNameSuffix(null); + + assertEquals("logging_event", resolver.getTableName(TableName.LOGGING_EVENT)); + assertEquals("event_id", resolver.getColumnName(ColumnName.EVENT_ID)); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/filter/LevelFilterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/filter/LevelFilterTest.java new file mode 100644 index 000000000..76af8453b --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/filter/LevelFilterTest.java @@ -0,0 +1,76 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.filter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.core.spi.FilterReply; + +public class LevelFilterTest { + + private final LoggerContext context = new LoggerContext(); + private final LevelFilter filter = new LevelFilter(); + + @Before + public void setUp() { + filter.setContext(context); + filter.setOnMatch(FilterReply.ACCEPT); + filter.setOnMismatch(FilterReply.DENY); + } + + private ILoggingEvent eventAt(Level level) { + return new LoggingEvent(getClass().getName(), context.getLogger("x"), level, "msg", null, null); + } + + @Test + public void startedFilterRepliesOnMatchForEventsOfTheConfiguredLevel() { + filter.setLevel(Level.WARN); + filter.start(); + assertTrue(filter.isStarted()); + assertEquals(FilterReply.ACCEPT, filter.decide(eventAt(Level.WARN))); + } + + @Test + public void startedFilterRepliesOnMismatchForEventsOfOtherLevels() { + filter.setLevel(Level.WARN); + filter.start(); + assertEquals(FilterReply.DENY, filter.decide(eventAt(Level.ERROR))); + assertEquals(FilterReply.DENY, filter.decide(eventAt(Level.INFO))); + } + + @Test + public void filterWithoutLevelDoesNotStart() { + filter.start(); + assertFalse(filter.isStarted()); + } + + @Test + public void unstartedFilterIsNeutralEvenForMatchingEvents() { + filter.setLevel(Level.WARN); + // not started + assertEquals(FilterReply.NEUTRAL, filter.decide(eventAt(Level.WARN))); + assertEquals(FilterReply.NEUTRAL, filter.decide(eventAt(Level.INFO))); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/filter/ThresholdFilterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/filter/ThresholdFilterTest.java new file mode 100644 index 000000000..bff9e1792 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/filter/ThresholdFilterTest.java @@ -0,0 +1,82 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.filter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.core.spi.FilterReply; + +public class ThresholdFilterTest { + + private final LoggerContext context = new LoggerContext(); + private final ThresholdFilter filter = new ThresholdFilter(); + + @Before + public void setUp() { + filter.setContext(context); + } + + private ILoggingEvent eventAt(Level level) { + return new LoggingEvent(getClass().getName(), context.getLogger("x"), level, "msg", null, null); + } + + @Test + public void eventsAtOrAboveThresholdAreNeutral() { + filter.setLevel("INFO"); + filter.start(); + assertTrue(filter.isStarted()); + assertEquals(FilterReply.NEUTRAL, filter.decide(eventAt(Level.INFO))); + assertEquals(FilterReply.NEUTRAL, filter.decide(eventAt(Level.ERROR))); + } + + @Test + public void eventsBelowThresholdAreDenied() { + filter.setLevel("INFO"); + filter.start(); + assertEquals(FilterReply.DENY, filter.decide(eventAt(Level.DEBUG))); + assertEquals(FilterReply.DENY, filter.decide(eventAt(Level.TRACE))); + } + + @Test + public void unknownLevelNameFallsBackToDebug() { + filter.setLevel("bogus"); + filter.start(); + assertEquals(FilterReply.NEUTRAL, filter.decide(eventAt(Level.DEBUG))); + assertEquals(FilterReply.DENY, filter.decide(eventAt(Level.TRACE))); + } + + @Test + public void filterWithoutLevelDoesNotStart() { + filter.start(); + assertFalse(filter.isStarted()); + } + + @Test + public void unstartedFilterIsNeutralEvenBelowThreshold() { + filter.setLevel("ERROR"); + // not started + assertEquals(FilterReply.NEUTRAL, filter.decide(eventAt(Level.DEBUG))); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/layout/TTLLLayoutTest.java b/logback-android/src/test/java/ch/qos/logback/classic/layout/TTLLLayoutTest.java index 154c2b8e9..58f93d2aa 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/layout/TTLLLayoutTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/layout/TTLLLayoutTest.java @@ -15,7 +15,9 @@ */ package ch.qos.logback.classic.layout; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; import static org.hamcrest.text.MatchesPattern.matchesPattern; import org.junit.Before; @@ -50,4 +52,32 @@ public void nullMessage() { String firstLine = result.split(CoreConstants.LINE_SEPARATOR)[0]; assertThat(firstLine, matchesPattern(TTLL_PREFIX_PATTERN + " - null")); } + + @Test + public void unstartedLayoutProducesEmptyString() { + TTLLLayout unstarted = new TTLLLayout(); + unstarted.setContext(context); + LoggingEvent event = new LoggingEvent("", logger, Level.INFO, "hello", null, null); + assertEquals("", unstarted.doLayout(event)); + } + + @Test + public void eventWithoutThrowableIsASingleLine() { + LoggingEvent event = new LoggingEvent("", logger, Level.WARN, "hello", null, null); + event.setThreadName("main"); + String result = layout.doLayout(event); + assertTrue(result.endsWith("] WARN " + TTLLLayoutTest.class.getName() + " - hello" + CoreConstants.LINE_SEPARATOR)); + assertEquals(1, result.split(CoreConstants.LINE_SEPARATOR).length); + } + + @Test + public void throwableStackTraceFollowsTheMessageLine() { + LoggingEvent event = new LoggingEvent("", logger, Level.ERROR, "failed", new IllegalStateException("boom"), null); + event.setThreadName("main"); + String result = layout.doLayout(event); + String[] lines = result.split(CoreConstants.LINE_SEPARATOR); + assertThat(lines[0], matchesPattern(TTLL_PREFIX_PATTERN + " - failed")); + assertEquals("java.lang.IllegalStateException: boom", lines[1]); + assertTrue(lines[2], lines[2].startsWith(CoreConstants.TAB + "at " + TTLLLayoutTest.class.getName())); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/log4j/XMLLayoutTest.java b/logback-android/src/test/java/ch/qos/logback/classic/log4j/XMLLayoutTest.java index 46b1de4ce..aec893b24 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/log4j/XMLLayoutTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/log4j/XMLLayoutTest.java @@ -17,7 +17,10 @@ import java.io.ByteArrayInputStream; import java.io.InputStream; +import java.lang.reflect.Field; +import java.util.Collections; import java.util.Iterator; +import java.util.Map; import javax.xml.XMLConstants; import javax.xml.namespace.NamespaceContext; @@ -208,4 +211,138 @@ public InputSource resolveEntity(String publicId, String systemId) { return builder.parse(new ByteArrayInputStream(output.getBytes("UTF-8"))); } + + @Test + public void locationInfoAndPropertiesAreOffByDefault() { + XMLLayout fresh = new XMLLayout(); + Assert.assertFalse(fresh.getLocationInfo()); + Assert.assertFalse(fresh.getProperties()); + fresh.setLocationInfo(true); + fresh.setProperties(true); + Assert.assertTrue(fresh.getLocationInfo()); + Assert.assertTrue(fresh.getProperties()); + } + + @Test + public void contentTypeIsTextXml() { + Assert.assertEquals("text/xml", layout.getContentType()); + } + + @Test + public void simpleEventRendersOnlyTheMessage() { + layout.setLocationInfo(false); + layout.setProperties(false); + LoggingEvent event = createSimpleEvent("hello"); + + Assert.assertEquals("\r\n" + + " hello\r\n" + + "\r\n\r\n\r\n", layout.doLayout(event)); + } + + @Test + public void noLocationInfoWhenDisabled() { + layout.setLocationInfo(false); + String result = layout.doLayout(createLoggingEvent()); + Assert.assertFalse(result.contains("")); + } + + @Test + public void noLocationInfoWhenCallerDataIsEmpty() { + LoggingEvent event = createSimpleEvent("hello"); + event.setCallerData(new StackTraceElement[0]); + Assert.assertFalse(layout.doLayout(event).contains("", "B.java", 7), + new StackTraceElement("c.D", "n", "D.java", 9)}); + String result = layout.doLayout(event); + Assert.assertTrue(result, result.contains(" \r\n")); + Assert.assertFalse(result.contains("c.D")); + } + + @Test + public void noPropertiesWhenDisabled() { + layout.setProperties(false); + LoggingEvent event = createSimpleEvent("hello"); + event.setMDCPropertyMap(Collections.singletonMap("k", "v")); + Assert.assertFalse(layout.doLayout(event).contains("")); + } + + @Test + public void noPropertiesWhenMdcMapIsEmpty() { + LoggingEvent event = createSimpleEvent("hello"); + event.setMDCPropertyMap(Collections.emptyMap()); + Assert.assertFalse(layout.doLayout(event).contains("")); + } + + @Test + public void noPropertiesWhenMdcMapIsNull() { + LoggingEvent event = new LoggingEvent("fqcn", root, Level.INFO, "hello", null, null) { + @Override + public Map getMDCPropertyMap() { + return null; + } + }; + event.setCallerData(new StackTraceElement[0]); + Assert.assertFalse(layout.doLayout(event).contains("")); + } + + @Test + public void propertiesAreRenderedWhenEnabled() { + LoggingEvent event = createSimpleEvent("hello"); + event.setCallerData(new StackTraceElement[0]); + event.setMDCPropertyMap(Collections.singletonMap("k", "v")); + Assert.assertTrue(layout.doLayout(event).endsWith( + " \r\n \r\n " + + "\r\n\r\n\r\n")); + } + + @Test + public void oversizedBufferIsReplacedAndPreviousEventDoesNotLeak() throws Exception { + layout.setLocationInfo(false); + layout.setProperties(false); + LoggingEvent small = createSimpleEvent("small"); + String expected = layout.doLayout(small); + + StringBuilder big = new StringBuilder(); + for (int i = 0; i < 3000; i++) { + big.append('x'); + } + Assert.assertTrue(layout.doLayout(createSimpleEvent(big.toString())).contains(big)); + Assert.assertTrue(bufferCapacity() > 2048); + + Assert.assertEquals(expected, layout.doLayout(small)); + Assert.assertTrue(bufferCapacity() <= 2048); + } + + private int bufferCapacity() throws Exception { + Field field = XMLLayout.class.getDeclaredField("buf"); + field.setAccessible(true); + return ((StringBuilder) field.get(layout)).capacity(); + } + + private LoggingEvent createSimpleEvent(String message) { + LoggingEvent event = new LoggingEvent("fqcn", lc.getLogger("com.example.Simple"), Level.INFO, message, null, null); + event.setThreadName("main"); + event.setTimeStamp(42); + return event; + } } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/pattern/color/HighlightingCompositeConverterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/pattern/color/HighlightingCompositeConverterTest.java new file mode 100644 index 000000000..3834dbd0d --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/pattern/color/HighlightingCompositeConverterTest.java @@ -0,0 +1,68 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.pattern.color; + +import static org.junit.Assert.assertEquals; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.PatternLayout; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggingEvent; + +public class HighlightingCompositeConverterTest { + + private static final String ESC = "\033["; + private static final String RESET = ESC + "0;39m"; + + private final LoggerContext context = new LoggerContext(); + private final PatternLayout layout = new PatternLayout(); + + @Before + public void setUp() { + layout.setContext(context); + layout.setPattern("%highlight(%msg)"); + layout.start(); + } + + private ILoggingEvent eventAt(Level level) { + return new LoggingEvent(getClass().getName(), context.getLogger("x"), level, "hi", null, null); + } + + @Test + public void errorIsBoldRed() { + assertEquals(ESC + "1;31m" + "hi" + RESET, layout.doLayout(eventAt(Level.ERROR))); + } + + @Test + public void warnIsRed() { + assertEquals(ESC + "31m" + "hi" + RESET, layout.doLayout(eventAt(Level.WARN))); + } + + @Test + public void infoIsBlue() { + assertEquals(ESC + "34m" + "hi" + RESET, layout.doLayout(eventAt(Level.INFO))); + } + + @Test + public void lowerLevelsUseTheDefaultColor() { + assertEquals(ESC + "39m" + "hi" + RESET, layout.doLayout(eventAt(Level.DEBUG))); + assertEquals(ESC + "39m" + "hi" + RESET, layout.doLayout(eventAt(Level.TRACE))); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/selector/DefaultContextSelectorTest.java b/logback-android/src/test/java/ch/qos/logback/classic/selector/DefaultContextSelectorTest.java new file mode 100644 index 000000000..ca2788354 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/selector/DefaultContextSelectorTest.java @@ -0,0 +1,65 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.selector; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +import java.util.Collections; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.classic.LoggerContext; + +public class DefaultContextSelectorTest { + + private final LoggerContext context = new LoggerContext(); + private DefaultContextSelector selector; + + @Before + public void setUp() { + context.setName("ctx"); + selector = new DefaultContextSelector(context); + } + + @Test + public void alwaysSelectsTheDefaultContext() { + assertSame(context, selector.getDefaultLoggerContext()); + assertSame(context, selector.getLoggerContext()); + } + + @Test + public void detachingAnyNameReturnsTheDefaultContext() { + assertSame(context, selector.detachLoggerContext("ctx")); + assertSame(context, selector.detachLoggerContext("other")); + // detaching does not remove the default context + assertSame(context, selector.getLoggerContext()); + } + + @Test + public void contextNamesListOnlyTheDefaultContext() { + assertEquals(Collections.singletonList("ctx"), selector.getContextNames()); + } + + @Test + public void lookupByNameFindsOnlyTheDefaultContext() { + assertSame(context, selector.getLoggerContext("ctx")); + assertNull(selector.getLoggerContext("other")); + assertNull(selector.getLoggerContext(null)); + } +} From 3b6665c2251d12eb3ac1ed65e0da8f1d9c3f887a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:09:02 +0000 Subject: [PATCH 49/79] test: pin XMLLayout buffer reset and bound AsyncAppender flush wait Removing XMLLayout's buf.setLength(0) survived the suite: consecutive layouts would concatenate. Assert that the second of two layouts carries only its own event, and compare the oversized layout exactly. Give the AsyncAppender discard test a generous maxFlushTime so a slow machine cannot drop the flushed WARN/ERROR events after the default 1s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../ch/qos/logback/classic/AsyncAppenderTest.java | 2 ++ .../qos/logback/classic/log4j/XMLLayoutTest.java | 14 +++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/logback-android/src/test/java/ch/qos/logback/classic/AsyncAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/classic/AsyncAppenderTest.java index c8044e567..52c31622a 100755 --- a/logback-android/src/test/java/ch/qos/logback/classic/AsyncAppenderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/AsyncAppenderTest.java @@ -107,6 +107,8 @@ public void discardableEventsAreDroppedWhenQueueIsBelowDiscardingThreshold() { asyncAppender.setQueueSize(10); // remaining capacity (at most 10) is always below this threshold asyncAppender.setDiscardingThreshold(11); + // stop() returns as soon as the worker has flushed; a generous bound keeps a slow machine from losing events + asyncAppender.setMaxFlushTime(60000); asyncAppender.start(); for (Level level : new Level[] {Level.TRACE, Level.DEBUG, Level.INFO, Level.WARN, Level.ERROR}) { diff --git a/logback-android/src/test/java/ch/qos/logback/classic/log4j/XMLLayoutTest.java b/logback-android/src/test/java/ch/qos/logback/classic/log4j/XMLLayoutTest.java index aec893b24..c59191785 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/log4j/XMLLayoutTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/log4j/XMLLayoutTest.java @@ -326,13 +326,25 @@ public void oversizedBufferIsReplacedAndPreviousEventDoesNotLeak() throws Except for (int i = 0; i < 3000; i++) { big.append('x'); } - Assert.assertTrue(layout.doLayout(createSimpleEvent(big.toString())).contains(big)); + Assert.assertEquals(expected.replace(">small<", ">" + big + "<"), + layout.doLayout(createSimpleEvent(big.toString()))); Assert.assertTrue(bufferCapacity() > 2048); Assert.assertEquals(expected, layout.doLayout(small)); Assert.assertTrue(bufferCapacity() <= 2048); } + @Test + public void consecutiveEventsDoNotAccumulateInTheOutput() { + layout.setLocationInfo(false); + layout.setProperties(false); + String first = layout.doLayout(createSimpleEvent("first")); + + String second = layout.doLayout(createSimpleEvent("second")); + + Assert.assertEquals(first.replace(">first<", ">second<"), second); + } + private int bufferCapacity() throws Exception { Field field = XMLLayout.class.getDeclaredField("buf"); field.setAccessible(true); From d9cda053b469276a513de0a6797ba20e04f07351 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:27:09 +0000 Subject: [PATCH 50/79] test: cover HTML layouts and the org.slf4j.impl binders Plain-JVM tests for HTMLLayout, HTMLLayoutBase, DefaultThrowableRenderer, UrlCssBuilder, and the org.slf4j.impl LoggerFactory, LoggerServiceProvider, StaticLoggerBinder, StaticMDCBinder and StaticMarkerBinder. They check: - HTML output: rows alternate even/odd, MDC columns are named after their key (or "MDC"), converter columns drop the "Converter" suffix, header cells whose name is null are skipped, a new table with a header row starts when TABLE_ROW_LIMIT is reached, and a bad pattern or a missing throwable renderer is reported and keeps the layout stopped - cause chains render with "Caused by:" and "... N common frames omitted" - effective converter maps with or without defaults or a context - LoggerFactory caches loggers and keeps the logger another thread cached first - LoggerServiceProvider and StaticLoggerBinder report auto-configuration and instantiation failures on stderr and keep initializing, print status errors only when no status listener is registered, and StaticLoggerBinder falls back to its default context, or throws when the context selector is null StaticLoggerBinder is exercised through fresh instances with a mocked ContextSelectorStaticBinder, so the JVM-wide singletons are left untouched. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../html/DefaultThrowableRendererTest.java | 108 +++++++ .../classic/html/HTMLLayoutJvmTest.java | 141 +++++++++ .../classic/html/UrlCssBuilderTest.java | 64 ++++ .../logback/core/html/HTMLLayoutBaseTest.java | 280 ++++++++++++++++++ .../org/slf4j/impl/LoggerFactoryTest.java | 74 +++++ .../slf4j/impl/LoggerServiceProviderTest.java | 169 +++++++++++ .../slf4j/impl/StaticLoggerBinderTest.java | 247 +++++++++++++++ .../org/slf4j/impl/StaticMDCBinderTest.java | 52 ++++ .../slf4j/impl/StaticMarkerBinderTest.java | 52 ++++ 9 files changed, 1187 insertions(+) create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/html/DefaultThrowableRendererTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/html/HTMLLayoutJvmTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/html/UrlCssBuilderTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/html/HTMLLayoutBaseTest.java create mode 100644 logback-android/src/test/java/org/slf4j/impl/LoggerFactoryTest.java create mode 100644 logback-android/src/test/java/org/slf4j/impl/LoggerServiceProviderTest.java create mode 100644 logback-android/src/test/java/org/slf4j/impl/StaticLoggerBinderTest.java create mode 100644 logback-android/src/test/java/org/slf4j/impl/StaticMDCBinderTest.java create mode 100644 logback-android/src/test/java/org/slf4j/impl/StaticMarkerBinderTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/classic/html/DefaultThrowableRendererTest.java b/logback-android/src/test/java/ch/qos/logback/classic/html/DefaultThrowableRendererTest.java new file mode 100644 index 000000000..a7db61e16 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/html/DefaultThrowableRendererTest.java @@ -0,0 +1,108 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.html; + +import static ch.qos.logback.classic.html.DefaultThrowableRenderer.TRACE_PREFIX; +import static ch.qos.logback.core.CoreConstants.LINE_SEPARATOR; +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.Test; + +import ch.qos.logback.classic.spi.DummyThrowableProxy; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.StackTraceElementProxy; + +public class DefaultThrowableRendererTest { + + private final DefaultThrowableRenderer renderer = new DefaultThrowableRenderer(); + + @Test + public void throwableWithoutCommonFramesListsEveryFrameWithoutCausedByOrOmissionNote() { + DummyThrowableProxy tp = proxy("java.lang.IllegalStateException", "bad ", 0, + frame("c1", "m1", 1), frame("c2", "m2", 2)); + + StringBuilder sb = new StringBuilder(); + renderer.render(sb, tp); + + assertEquals("java.lang.IllegalStateException: bad <state>" + LINE_SEPARATOR + + TRACE_PREFIX + "at c1.m1(f1:1)" + LINE_SEPARATOR + + TRACE_PREFIX + "at c2.m2(f2:2)" + LINE_SEPARATOR, sb.toString()); + } + + @Test + public void throwableSharingFramesIsMarkedAsCauseAndSummarizesTheOmittedFrames() { + DummyThrowableProxy tp = proxy("java.io.IOException", "disk full", 2, + frame("c1", "m1", 1), frame("c2", "m2", 2), frame("c3", "m3", 3)); + + StringBuilder sb = new StringBuilder(); + renderer.render(sb, tp); + + assertEquals("
Caused by: java.io.IOException: disk full" + LINE_SEPARATOR + + TRACE_PREFIX + "at c1.m1(f1:1)" + LINE_SEPARATOR + + TRACE_PREFIX + "\t... 2 common frames omitted" + LINE_SEPARATOR, sb.toString()); + } + + @Test + public void printFirstLinePrefixesCausedByOnlyWhenFramesAreShared() { + StringBuilder top = new StringBuilder(); + renderer.printFirstLine(top, proxy("a.Top", "top", 0)); + assertEquals("a.Top: top" + LINE_SEPARATOR, top.toString()); + + StringBuilder cause = new StringBuilder(); + renderer.printFirstLine(cause, proxy("a.Cause", "cause", 1)); + assertEquals("
Caused by: a.Cause: cause" + LINE_SEPARATOR, cause.toString()); + } + + @Test + public void eventRendersWholeCauseChainInsideOneExceptionRow() { + DummyThrowableProxy cause = proxy("java.io.IOException", "inner", 1, + frame("c2", "m2", 2), frame("c3", "m3", 3)); + DummyThrowableProxy top = proxy("java.lang.RuntimeException", "outer", 0, + frame("c1", "m1", 1), frame("c3", "m3", 3)); + top.setCause(cause); + ILoggingEvent event = mock(ILoggingEvent.class); + when(event.getThrowableProxy()).thenReturn(top); + + StringBuilder sb = new StringBuilder(); + renderer.render(sb, event); + + assertEquals("" + + "java.lang.RuntimeException: outer" + LINE_SEPARATOR + + TRACE_PREFIX + "at c1.m1(f1:1)" + LINE_SEPARATOR + + TRACE_PREFIX + "at c3.m3(f3:3)" + LINE_SEPARATOR + + "
Caused by: java.io.IOException: inner" + LINE_SEPARATOR + + TRACE_PREFIX + "at c2.m2(f2:2)" + LINE_SEPARATOR + + TRACE_PREFIX + "\t... 1 common frames omitted" + LINE_SEPARATOR + + "", sb.toString()); + } + + private static DummyThrowableProxy proxy(String className, String message, int commonFrames, + StackTraceElementProxy... frames) { + DummyThrowableProxy tp = new DummyThrowableProxy(); + tp.setClassName(className); + tp.setMessage(message); + tp.setCommonFramesCount(commonFrames); + tp.setStackTraceElementProxyArray(frames); + return tp; + } + + private static StackTraceElementProxy frame(String className, String method, int line) { + return new StackTraceElementProxy( + new StackTraceElement(className, method, "f" + line, line)); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/html/HTMLLayoutJvmTest.java b/logback-android/src/test/java/ch/qos/logback/classic/html/HTMLLayoutJvmTest.java new file mode 100644 index 000000000..a301d9b49 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/html/HTMLLayoutJvmTest.java @@ -0,0 +1,141 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.html; + +import static ch.qos.logback.core.CoreConstants.LINE_SEPARATOR; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.core.html.IThrowableRenderer; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; + +/** + * Plain-JVM tests of {@link HTMLLayout}; the XHTML-validating tests live in the + * Robolectric-run {@link HTMLLayoutTest}. + */ +public class HTMLLayoutJvmTest { + + private LoggerContext lc; + private Logger logger; + private HTMLLayout layout; + + @Before + public void setUp() { + lc = new LoggerContext(); + logger = lc.getLogger("html.jvm"); + layout = new HTMLLayout(); + layout.setContext(lc); + } + + @Test + public void defaultsToFullPatternDefaultCssAndDefaultThrowableRenderer() { + assertEquals("%date%thread%level%logger%mdc%msg", layout.getPattern()); + assertTrue(layout.getCssBuilder() instanceof DefaultCssBuilder); + assertTrue(layout.getThrowableRenderer() instanceof DefaultThrowableRenderer); + } + + @Test + public void startsWhenThrowableRendererIsSet() { + layout.start(); + + assertTrue(layout.isStarted()); + new StatusChecker(lc).assertIsErrorFree(); + } + + @Test + public void refusesToStartWithoutThrowableRenderer() { + layout.setThrowableRenderer(null); + assertNull(layout.getThrowableRenderer()); + + layout.start(); + + assertFalse(layout.isStarted()); + new StatusChecker(lc).assertContainsMatch(Status.ERROR, "ThrowableRender cannot be null."); + } + + @Test + public void consecutiveRowsAlternateBetweenEvenAndOdd() { + layout.setPattern("%msg"); + layout.start(); + + assertEquals(LINE_SEPARATOR + "" + LINE_SEPARATOR + + "one" + LINE_SEPARATOR + + "" + LINE_SEPARATOR, layout.doLayout(event(Level.INFO, "one"))); + assertEquals(LINE_SEPARATOR + "" + LINE_SEPARATOR + + "two" + LINE_SEPARATOR + + "" + LINE_SEPARATOR, layout.doLayout(event(Level.WARN, "two"))); + assertTrue(layout.doLayout(event(Level.ERROR, "three")).contains("")); + } + + @Test + public void mdcColumnIsNamedAfterItsKeyOrMdcWhenNoKeyIsGiven() { + layout.setPattern("%mdc{user}%mdc"); + layout.start(); + + String header = layout.getPresentationHeader(); + assertTrue(header, header.endsWith("" + LINE_SEPARATOR + + "user" + LINE_SEPARATOR + + "MDC" + LINE_SEPARATOR + + "" + LINE_SEPARATOR)); + + LoggingEvent e = event(Level.INFO, "msg"); + e.setMDCPropertyMap(Collections.singletonMap("user", "alice")); + String row = layout.doLayout(e); + assertTrue(row, row.contains("alice" + LINE_SEPARATOR + + "user=alice")); + } + + @Test + public void configuredThrowableRendererRendersEventsCarryingAThrowable() { + layout.setPattern("%msg"); + TaggingThrowableRenderer renderer = new TaggingThrowableRenderer(); + layout.setThrowableRenderer(renderer); + assertSame(renderer, layout.getThrowableRenderer()); + layout.start(); + + String withThrowable = layout.doLayout(new LoggingEvent(getClass().getName(), logger, + Level.ERROR, "failed", new IllegalStateException("boom"), null)); + String withoutThrowable = layout.doLayout(event(Level.INFO, "fine")); + + assertTrue(withThrowable, withThrowable.endsWith("" + LINE_SEPARATOR + "[boom]")); + assertTrue(withoutThrowable, withoutThrowable.endsWith("" + LINE_SEPARATOR)); + } + + static class TaggingThrowableRenderer implements IThrowableRenderer { + @Override + public void render(StringBuilder sbuf, ILoggingEvent event) { + sbuf.append('[').append(event.getThrowableProxy().getMessage()).append(']'); + } + } + + private LoggingEvent event(Level level, String msg) { + return new LoggingEvent(getClass().getName(), logger, level, msg, null, null); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/html/UrlCssBuilderTest.java b/logback-android/src/test/java/ch/qos/logback/classic/html/UrlCssBuilderTest.java new file mode 100644 index 000000000..ac69a3d09 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/html/UrlCssBuilderTest.java @@ -0,0 +1,64 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.html; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import ch.qos.logback.classic.LoggerContext; + +public class UrlCssBuilderTest { + + @Test + public void defaultsToLogbackClassicStylesheet() { + UrlCssBuilder builder = new UrlCssBuilder(); + assertEquals("http://logback.qos.ch/css/classic.css", builder.getUrl()); + + StringBuilder sb = new StringBuilder(""); + builder.addCss(sb); + assertEquals("", sb.toString()); + } + + @Test + public void linksToConfiguredUrl() { + UrlCssBuilder builder = new UrlCssBuilder(); + builder.setUrl("https://example.invalid/my.css"); + assertEquals("https://example.invalid/my.css", builder.getUrl()); + + StringBuilder sb = new StringBuilder(); + builder.addCss(sb); + assertEquals("", + sb.toString()); + } + + @Test + public void htmlLayoutFileHeaderUsesTheLinkInsteadOfInlineStyles() { + UrlCssBuilder builder = new UrlCssBuilder(); + builder.setUrl("my.css"); + HTMLLayout layout = new HTMLLayout(); + layout.setContext(new LoggerContext()); + layout.setCssBuilder(builder); + + String header = layout.getFileHeader(); + + assertTrue(header, header.contains("")); + assertFalse(header, header.contains(" { + @Override + public String convert(Object event) { + return String.valueOf(event).toUpperCase(Locale.US); + } + } + + /** Named without the "Converter" suffix, so its column keeps the full simple name. */ + public static class Echo extends DynamicConverter { + @Override + public String convert(Object event) { + return String.valueOf(event); + } + } + + static class TestHTMLLayout extends HTMLLayoutBase { + Map defaultConverterMap = new HashMap(); + boolean hideLiteralColumns; + + TestHTMLLayout() { + defaultConverterMap.put("shout", ShoutConverter.class.getName()); + defaultConverterMap.put("echo", Echo.class.getName()); + } + + @Override + protected Map getDefaultConverterMap() { + return defaultConverterMap; + } + + @Override + protected String computeConverterName(Converter c) { + if (hideLiteralColumns && c instanceof LiteralConverter) { + return null; + } + return super.computeConverterName(c); + } + + @Override + public String doLayout(Object event) { + StringBuilder sb = new StringBuilder(); + startNewTableIfLimitReached(sb); + counter++; + for (Converter c = head; c != null; c = c.getNext()) { + sb.append("").append(c.convert(event)).append(""); + } + return sb.toString(); + } + } + + private static final String SHOUT_ECHO_HEADER_ROW = "" + LINE_SEPARATOR + + "Shout" + LINE_SEPARATOR + + "Echo" + LINE_SEPARATOR + + "" + LINE_SEPARATOR; + + private Context context; + private TestHTMLLayout layout; + + @Before + public void setUp() { + context = new ContextBase(); + layout = new TestHTMLLayout(); + layout.setContext(context); + } + + @Test + public void patternIsSettable() { + layout.setPattern("%shout"); + assertEquals("%shout", layout.getPattern()); + } + + @Test + public void titleDefaultsToLogbackLogMessagesAndIsWrittenIntoTheFileHeader() { + assertEquals("Logback Log Messages", layout.getTitle()); + layout.setTitle("My app"); + assertEquals("My app", layout.getTitle()); + layout.setCssBuilder(new CssBuilder() { + @Override + public void addCss(StringBuilder sbuf) { + sbuf.append(""); + } + }); + + assertEquals("" + LINE_SEPARATOR + + "" + LINE_SEPARATOR + + " " + LINE_SEPARATOR + + " My <b>app</b>" + LINE_SEPARATOR + + "" + LINE_SEPARATOR + + " " + LINE_SEPARATOR + + "" + LINE_SEPARATOR, layout.getFileHeader()); + } + + @Test + public void cssBuilderIsSettable() { + CssBuilder css = new CssBuilder() { + @Override + public void addCss(StringBuilder sbuf) { + } + }; + layout.setCssBuilder(css); + assertSame(css, layout.getCssBuilder()); + } + + @Test + public void contentTypeIsTextHtml() { + assertEquals("text/html", layout.getContentType()); + } + + @Test + public void footersCloseTheTableAndTheDocument() { + assertEquals("", layout.getPresentationFooter()); + assertEquals(LINE_SEPARATOR + "", layout.getFileFooter()); + } + + @Test + public void columnsAreNamedAfterConverterClassWithoutTheConverterSuffix() { + layout.setPattern("%shout%echo"); + layout.start(); + + assertTrue(layout.isStarted()); + String header = layout.getPresentationHeader(); + assertTrue(header, header.endsWith("" + LINE_SEPARATOR + + SHOUT_ECHO_HEADER_ROW)); + assertEquals("", layout.doLayout("hi")); + } + + @Test + public void headerRowSkipsConvertersWhoseNameIsNull() { + layout.hideLiteralColumns = true; + layout.setPattern("%shout - %echo"); + layout.start(); + + String header = layout.getPresentationHeader(); + assertTrue(header, header.endsWith(SHOUT_ECHO_HEADER_ROW)); + assertFalse(header, header.contains("Literal")); + } + + @Test + public void headerRowListsLiteralConvertersWhenTheyHaveAName() { + layout.setPattern("%shout - %echo"); + layout.start(); + + assertTrue(layout.getPresentationHeader().contains("")); + } + + @Test + public void incorrectPatternIsReportedAndLayoutDoesNotStart() { + layout.setPattern("hello%(abc"); + layout.start(); + + assertFalse(layout.isStarted()); + StatusChecker checker = new StatusChecker(context); + checker.assertContainsMatch(Status.ERROR, "Incorrect pattern found"); + checker.asssertContainsException(ScanException.class); + } + + @Test + public void reachingTheRowLimitStartsNewTableWithHeaderRowAndResetsCounter() { + layout.setPattern("%shout%echo"); + layout.start(); + layout.counter = CoreConstants.TABLE_ROW_LIMIT; + + StringBuilder sb = new StringBuilder(); + layout.startNewTableIfLimitReached(sb); + + assertEquals("
HIhiLiteral
" + LINE_SEPARATOR + + "

" + LINE_SEPARATOR + + SHOUT_ECHO_HEADER_ROW, sb.toString()); + assertEquals(0, layout.counter); + } + + @Test + public void belowTheRowLimitNoNewTableIsStarted() { + layout.setPattern("%shout"); + layout.start(); + layout.counter = CoreConstants.TABLE_ROW_LIMIT - 1; + + StringBuilder sb = new StringBuilder(); + layout.startNewTableIfLimitReached(sb); + + assertEquals("", sb.toString()); + assertEquals(CoreConstants.TABLE_ROW_LIMIT - 1, layout.counter); + } + + @Test + public void rowAfterTheLimitIsPrecededByANewTable() { + layout.setPattern("%echo"); + layout.start(); + layout.counter = CoreConstants.TABLE_ROW_LIMIT - 1; + + assertEquals("", layout.doLayout("last")); + String next = layout.doLayout("next"); + + assertTrue(next, next.startsWith("
last
")); + assertTrue(next, next.endsWith("next")); + assertEquals(1, layout.counter); + } + + @Test + public void contextConversionRulesOverrideDefaultConverters() { + context.putObject(CoreConstants.PATTERN_RULE_REGISTRY, + Collections.singletonMap("shout", Echo.class.getName())); + + Map effective = layout.getEffectiveConverterMap(); + + assertEquals(2, effective.size()); + assertEquals(Echo.class.getName(), effective.get("shout")); + assertEquals(Echo.class.getName(), effective.get("echo")); + } + + @Test + public void effectiveConverterMapWithoutDefaultsHoldsOnlyContextRules() { + layout.defaultConverterMap = null; + context.putObject(CoreConstants.PATTERN_RULE_REGISTRY, + Collections.singletonMap("echo", Echo.class.getName())); + + assertEquals(Collections.singletonMap("echo", Echo.class.getName()), + layout.getEffectiveConverterMap()); + } + + @Test + public void effectiveConverterMapWithoutContextHoldsOnlyDefaults() { + TestHTMLLayout contextless = new TestHTMLLayout(); + + Map effective = contextless.getEffectiveConverterMap(); + + assertEquals(contextless.defaultConverterMap, effective); + } + + @Test + public void effectiveConverterMapIsEmptyWithoutDefaultsOrContextRules() { + layout.defaultConverterMap = null; + + assertTrue(layout.getEffectiveConverterMap().isEmpty()); + } +} diff --git a/logback-android/src/test/java/org/slf4j/impl/LoggerFactoryTest.java b/logback-android/src/test/java/org/slf4j/impl/LoggerFactoryTest.java new file mode 100644 index 000000000..63299a933 --- /dev/null +++ b/logback-android/src/test/java/org/slf4j/impl/LoggerFactoryTest.java @@ -0,0 +1,74 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.slf4j.impl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.concurrent.ConcurrentHashMap; + +import org.junit.Test; +import org.slf4j.Logger; + +public class LoggerFactoryTest { + + private final LoggerFactory factory = new LoggerFactory(); + + @Test + public void createsLogbackLoggersFromItsOwnContext() { + Logger logger = factory.getLogger("a.b.C"); + + assertTrue(logger instanceof ch.qos.logback.classic.Logger); + assertEquals("a.b.C", logger.getName()); + assertSame(factory.loggerContext, + ((ch.qos.logback.classic.Logger) logger).getLoggerContext()); + } + + @Test + public void returnsTheCachedLoggerForARepeatedName() { + Logger first = factory.getLogger("repeated"); + + assertSame(first, factory.getLogger("repeated")); + assertSame(first, factory.loggerMap.get("repeated")); + } + + @Test + public void distinctNamesGetDistinctLoggers() { + assertNotSame(factory.getLogger("one"), factory.getLogger("two")); + assertEquals(2, factory.loggerMap.size()); + } + + @Test + public void loggerCachedConcurrentlyByAnotherThreadWinsOverTheNewOne() { + final Logger cachedByOtherThread = new LoggerFactory().getLogger("raced"); + // simulate another thread caching a logger between the lookup and putIfAbsent + factory.loggerMap = new ConcurrentHashMap() { + @Override + public Logger get(Object key) { + Logger existing = super.get(key); + if (existing == null) { + put((String) key, cachedByOtherThread); + } + return existing; + } + }; + + assertSame(cachedByOtherThread, factory.getLogger("raced")); + assertSame(cachedByOtherThread, factory.loggerMap.get("raced")); + } +} diff --git a/logback-android/src/test/java/org/slf4j/impl/LoggerServiceProviderTest.java b/logback-android/src/test/java/org/slf4j/impl/LoggerServiceProviderTest.java new file mode 100644 index 000000000..256d6af0f --- /dev/null +++ b/logback-android/src/test/java/org/slf4j/impl/LoggerServiceProviderTest.java @@ -0,0 +1,169 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.slf4j.impl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mockConstruction; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedConstruction; +import org.slf4j.helpers.BasicMarkerFactory; + +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.util.ContextInitializer; +import ch.qos.logback.classic.util.LogbackMDCAdapter; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.joran.spi.JoranException; +import ch.qos.logback.core.status.ErrorStatus; +import ch.qos.logback.core.status.NopStatusListener; +import ch.qos.logback.core.util.StatusPrinter; + +public class LoggerServiceProviderTest { + + /** What the (mocked) auto-configuration does to the provider's default context. */ + interface AutoConfig { + void run(LoggerContext defaultContext) throws Exception; + } + + private final ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + private final ByteArrayOutputStream statusOutput = new ByteArrayOutputStream(); + private PrintStream originalStderr; + private PrintStream originalStatusPrintStream; + + private final LoggerServiceProvider provider = new LoggerServiceProvider(); + /** The contexts handed to each ContextInitializer created during {@link #initialize}. */ + private final List autoConfiguredContexts = new ArrayList(); + + @Before + public void setUp() throws Exception { + originalStderr = System.err; + System.setErr(new PrintStream(stderr, true, "UTF-8")); + originalStatusPrintStream = statusPrinterStream(); + StatusPrinter.setPrintStream(new PrintStream(statusOutput, true, "UTF-8")); + } + + @After + public void tearDown() { + System.setErr(originalStderr); + StatusPrinter.setPrintStream(originalStatusPrintStream); + } + + @Test + public void requestsTheSlf4jApiVersionItIsCompiledAgainst() { + assertEquals("2.0.7", provider.getRequestedApiVersion()); + assertEquals(LoggerServiceProvider.REQUESTED_API_VERSION, provider.getRequestedApiVersion()); + } + + @Test + public void initializeAutoConfiguresAndStartsTheDefaultContextAndCreatesFactories() throws Exception { + initialize(null); + + assertDefaultContextStartedAndFactoriesCreated(); + assertEquals(1, autoConfiguredContexts.size()); + assertSame(provider.getLoggerFactory(), autoConfiguredContexts.get(0)); + assertEquals("", stderr.toString("UTF-8")); + } + + @Test + public void autoConfigurationFailureIsReportedAndInitializationContinues() throws Exception { + initialize(context -> { + throw new JoranException("broken config"); + }); + + String err = stderr.toString("UTF-8"); + assertTrue(err, err.contains("Failed to auto configure default logger context")); + assertTrue(err, err.contains("broken config")); + assertDefaultContextStartedAndFactoriesCreated(); + } + + @Test + public void unexpectedExceptionDuringConfigurationIsReportedAndInitializationContinues() + throws Exception { + initialize(context -> { + throw new IllegalStateException("unexpected"); + }); + + String err = stderr.toString("UTF-8"); + assertTrue(err, err.contains("Failed to instantiate [" + LoggerContext.class.getName() + "]")); + assertTrue(err, err.contains("unexpected")); + assertDefaultContextStartedAndFactoriesCreated(); + } + + @Test + public void configurationErrorsArePrintedWhenNoStatusListenerIsRegistered() throws Exception { + initialize(context -> + context.getStatusManager().add(new ErrorStatus("configuration went wrong", context))); + + String printed = statusOutput.toString("UTF-8"); + assertTrue(printed, printed.contains("configuration went wrong")); + } + + @Test + public void configurationErrorsAreNotPrintedWhenAStatusListenerIsRegistered() throws Exception { + initialize(context -> { + context.getStatusManager().add(new NopStatusListener()); + context.getStatusManager().add(new ErrorStatus("configuration went wrong", context)); + }); + + assertEquals("", statusOutput.toString("UTF-8")); + } + + /** + * Initializes {@link #provider} with a {@link ContextInitializer} whose auto-configuration + * does nothing, or runs {@code autoConfig} on the provider's default context when given. + */ + private void initialize(final AutoConfig autoConfig) { + try (MockedConstruction ignored = mockConstruction(ContextInitializer.class, + (initializer, ctx) -> { + final LoggerContext defaultContext = (LoggerContext) ctx.arguments().get(0); + autoConfiguredContexts.add(defaultContext); + doAnswer(invocation -> { + if (autoConfig != null) { + autoConfig.run(defaultContext); + } + return null; + }).when(initializer).autoConfig(); + })) { + provider.initialize(); + } + } + + private void assertDefaultContextStartedAndFactoriesCreated() { + assertTrue(provider.getLoggerFactory() instanceof LoggerContext); + LoggerContext context = (LoggerContext) provider.getLoggerFactory(); + assertEquals(CoreConstants.DEFAULT_CONTEXT_NAME, context.getName()); + assertTrue(context.isStarted()); + assertTrue(provider.getMarkerFactory() instanceof BasicMarkerFactory); + assertTrue(provider.getMDCAdapter() instanceof LogbackMDCAdapter); + } + + private static PrintStream statusPrinterStream() throws Exception { + Field ps = StatusPrinter.class.getDeclaredField("ps"); + ps.setAccessible(true); + return (PrintStream) ps.get(null); + } +} diff --git a/logback-android/src/test/java/org/slf4j/impl/StaticLoggerBinderTest.java b/logback-android/src/test/java/org/slf4j/impl/StaticLoggerBinderTest.java new file mode 100644 index 000000000..80f5cbe07 --- /dev/null +++ b/logback-android/src/test/java/org/slf4j/impl/StaticLoggerBinderTest.java @@ -0,0 +1,247 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.slf4j.impl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import org.slf4j.ILoggerFactory; + +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.selector.ContextSelector; +import ch.qos.logback.classic.util.ContextInitializer; +import ch.qos.logback.classic.util.ContextSelectorStaticBinder; +import ch.qos.logback.classic.util.LogbackMDCAdapter; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.joran.spi.JoranException; +import ch.qos.logback.core.status.ErrorStatus; +import ch.qos.logback.core.status.NopStatusListener; +import ch.qos.logback.core.util.StatusPrinter; + +/** + * Exercises fresh {@link StaticLoggerBinder} instances (built through the private + * constructor) so that the JVM-wide {@link StaticLoggerBinder#getSingleton() singleton} + * and {@link ContextSelectorStaticBinder} are left untouched. + */ +public class StaticLoggerBinderTest { + + /** What the (mocked) auto-configuration does to the binder's default context. */ + interface AutoConfig { + void run(LoggerContext defaultContext) throws Exception; + } + + private final ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + private final ByteArrayOutputStream statusOutput = new ByteArrayOutputStream(); + private PrintStream originalStderr; + private PrintStream originalStatusPrintStream; + + private final ContextSelectorStaticBinder selectorBinder = mock(ContextSelectorStaticBinder.class); + private final ContextSelector selector = mock(ContextSelector.class); + private final LoggerContext selectedContext = new LoggerContext(); + + @Before + public void setUp() throws Exception { + // initialize the class (and its singleton) before any static/construction mocking + StaticLoggerBinder.getSingleton(); + originalStderr = System.err; + System.setErr(new PrintStream(stderr, true, "UTF-8")); + originalStatusPrintStream = statusPrinterStream(); + StatusPrinter.setPrintStream(new PrintStream(statusOutput, true, "UTF-8")); + when(selector.getLoggerContext()).thenReturn(selectedContext); + } + + @After + public void tearDown() { + System.setErr(originalStderr); + StatusPrinter.setPrintStream(originalStatusPrintStream); + } + + @Test + public void singletonIsTheSameInstanceOnEveryCall() { + StaticLoggerBinder singleton = StaticLoggerBinder.getSingleton(); + + assertNotNull(singleton); + assertSame(singleton, StaticLoggerBinder.getSingleton()); + } + + @Test + public void requestsTheSameApiVersionAsTheServiceProvider() throws Exception { + assertEquals("2.0.7", newBinder().getRequestedApiVersion()); + } + + @Test + public void markerFactoryIsTheStaticMarkerBindersFactory() throws Exception { + assertSame(StaticMarkerBinder.getSingleton().getMarkerFactory(), newBinder().getMarkerFactory()); + } + + @Test + public void mdcAdapterIsALogbackMdcAdapter() throws Exception { + assertTrue(newBinder().getMDCAdapter() instanceof LogbackMDCAdapter); + } + + @Test + public void loggerFactoryClassNameIsTheContextSelectorBinder() throws Exception { + assertEquals("ch.qos.logback.classic.util.ContextSelectorStaticBinder", + newBinder().getLoggerFactoryClassStr()); + } + + @Test + public void uninitializedBinderHandsOutItsOwnDefaultContext() throws Exception { + StaticLoggerBinder binder = newBinder(); + + ILoggerFactory factory = binder.getLoggerFactory(); + + assertTrue(factory instanceof LoggerContext); + assertEquals(CoreConstants.DEFAULT_CONTEXT_NAME, ((LoggerContext) factory).getName()); + assertSame(factory, binder.getLoggerFactory()); + assertNotSame(factory, newBinder().getLoggerFactory()); + } + + @Test + public void initializedBinderHandsOutTheContextOfTheSelector() throws Exception { + when(selectorBinder.getContextSelector()).thenReturn(selector); + + StaticLoggerBinder binder = newInitializedBinder(null); + + assertSame(selectedContext, binder.getLoggerFactory()); + ArgumentCaptor defaultContext = ArgumentCaptor.forClass(LoggerContext.class); + verify(selectorBinder).init(defaultContext.capture(), notNull()); + assertEquals(CoreConstants.DEFAULT_CONTEXT_NAME, defaultContext.getValue().getName()); + assertEquals("", stderr.toString("UTF-8")); + } + + @Test + public void initializedBinderWithoutContextSelectorIsInIllegalState() throws Exception { + when(selectorBinder.getContextSelector()).thenReturn(null); + StaticLoggerBinder binder = newInitializedBinder(null); + + IllegalStateException e = assertThrows(IllegalStateException.class, binder::getLoggerFactory); + + assertEquals("contextSelector cannot be null. See also " + CoreConstants.CODES_URL + "#null_CS", + e.getMessage()); + } + + @Test + public void autoConfigurationFailureIsReportedAndBinderStillInitializes() throws Exception { + when(selectorBinder.getContextSelector()).thenReturn(selector); + + StaticLoggerBinder binder = newInitializedBinder(context -> { + throw new JoranException("broken config"); + }); + + String err = stderr.toString("UTF-8"); + assertTrue(err, err.contains("Failed to auto configure default logger context")); + assertTrue(err, err.contains("broken config")); + assertSame(selectedContext, binder.getLoggerFactory()); + } + + @Test + public void contextSelectorFailureIsReportedAndBinderFallsBackToItsDefaultContext() throws Exception { + when(selectorBinder.getContextSelector()).thenReturn(selector); + doThrow(new ClassNotFoundException("com.example.NoSuchSelector")) + .when(selectorBinder).init(any(LoggerContext.class), any()); + + StaticLoggerBinder binder = newInitializedBinder(null); + + String err = stderr.toString("UTF-8"); + assertTrue(err, err.contains("Failed to instantiate [" + LoggerContext.class.getName() + "]")); + assertTrue(err, err.contains("com.example.NoSuchSelector")); + ILoggerFactory factory = binder.getLoggerFactory(); + assertNotSame(selectedContext, factory); + assertEquals(CoreConstants.DEFAULT_CONTEXT_NAME, ((LoggerContext) factory).getName()); + verify(selectorBinder, never()).getContextSelector(); + } + + @Test + public void configurationErrorsArePrintedWhenNoStatusListenerIsRegistered() throws Exception { + newInitializedBinder(context -> + context.getStatusManager().add(new ErrorStatus("configuration went wrong", context))); + + String printed = statusOutput.toString("UTF-8"); + assertTrue(printed, printed.contains("configuration went wrong")); + } + + @Test + public void configurationErrorsAreNotPrintedWhenAStatusListenerIsRegistered() throws Exception { + newInitializedBinder(context -> { + context.getStatusManager().add(new NopStatusListener()); + context.getStatusManager().add(new ErrorStatus("configuration went wrong", context)); + }); + + assertEquals("", statusOutput.toString("UTF-8")); + } + + private static StaticLoggerBinder newBinder() throws Exception { + Constructor constructor = StaticLoggerBinder.class.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } + + /** + * Builds a binder bound to {@link #selectorBinder} and initializes it with a + * {@link ContextInitializer} whose auto-configuration does nothing, or runs + * {@code autoConfig} on the binder's default context when given. + */ + private StaticLoggerBinder newInitializedBinder(final AutoConfig autoConfig) throws Exception { + try (MockedStatic binders = mockStatic(ContextSelectorStaticBinder.class); + MockedConstruction ignored = mockConstruction(ContextInitializer.class, + (initializer, ctx) -> { + final LoggerContext defaultContext = (LoggerContext) ctx.arguments().get(0); + doAnswer(invocation -> { + if (autoConfig != null) { + autoConfig.run(defaultContext); + } + return null; + }).when(initializer).autoConfig(); + })) { + binders.when(ContextSelectorStaticBinder::getSingleton).thenReturn(selectorBinder); + StaticLoggerBinder binder = newBinder(); + binder.initialize(); + return binder; + } + } + + private static PrintStream statusPrinterStream() throws Exception { + Field ps = StatusPrinter.class.getDeclaredField("ps"); + ps.setAccessible(true); + return (PrintStream) ps.get(null); + } +} diff --git a/logback-android/src/test/java/org/slf4j/impl/StaticMDCBinderTest.java b/logback-android/src/test/java/org/slf4j/impl/StaticMDCBinderTest.java new file mode 100644 index 000000000..eb2a034f0 --- /dev/null +++ b/logback-android/src/test/java/org/slf4j/impl/StaticMDCBinderTest.java @@ -0,0 +1,52 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.slf4j.impl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; +import org.slf4j.spi.MDCAdapter; + +import ch.qos.logback.classic.util.LogbackMDCAdapter; + +public class StaticMDCBinderTest { + + @Test + public void singletonIsTheSharedInstance() { + assertNotNull(StaticMDCBinder.SINGLETON); + assertSame(StaticMDCBinder.SINGLETON, StaticMDCBinder.getSingleton()); + } + + @Test + public void providesAFreshLogbackMdcAdapterOnEachCall() { + MDCAdapter first = StaticMDCBinder.getSingleton().getMDCA(); + MDCAdapter second = StaticMDCBinder.getSingleton().getMDCA(); + + assertTrue(first instanceof LogbackMDCAdapter); + assertTrue(second instanceof LogbackMDCAdapter); + assertNotSame(first, second); + } + + @Test + public void adapterClassNameIsLogbackMdcAdapter() { + assertEquals("ch.qos.logback.classic.util.LogbackMDCAdapter", + StaticMDCBinder.getSingleton().getMDCAdapterClassStr()); + } +} diff --git a/logback-android/src/test/java/org/slf4j/impl/StaticMarkerBinderTest.java b/logback-android/src/test/java/org/slf4j/impl/StaticMarkerBinderTest.java new file mode 100644 index 000000000..dc34312c5 --- /dev/null +++ b/logback-android/src/test/java/org/slf4j/impl/StaticMarkerBinderTest.java @@ -0,0 +1,52 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.slf4j.impl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; +import org.slf4j.IMarkerFactory; +import org.slf4j.Marker; +import org.slf4j.helpers.BasicMarkerFactory; + +public class StaticMarkerBinderTest { + + @Test + public void singletonIsTheSharedInstance() { + assertNotNull(StaticMarkerBinder.SINGLETON); + assertSame(StaticMarkerBinder.SINGLETON, StaticMarkerBinder.getSingleton()); + } + + @Test + public void markerFactoryIsOneSharedBasicMarkerFactory() { + IMarkerFactory factory = StaticMarkerBinder.getSingleton().getMarkerFactory(); + + assertTrue(factory instanceof BasicMarkerFactory); + assertSame(factory, StaticMarkerBinder.getSingleton().getMarkerFactory()); + Marker marker = factory.getMarker("STATIC_MARKER_BINDER_TEST"); + assertSame(marker, StaticMarkerBinder.getSingleton().getMarkerFactory() + .getMarker("STATIC_MARKER_BINDER_TEST")); + } + + @Test + public void markerFactoryClassNameIsBasicMarkerFactory() { + assertEquals("org.slf4j.helpers.BasicMarkerFactory", + StaticMarkerBinder.getSingleton().getMarkerFactoryClassStr()); + } +} From f60a14030c67b849b781afb4fe783d3ebc067d12 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:03:23 +0000 Subject: [PATCH 51/79] test: pin HTML escaping and logger caching; make stderr checks order-proof - DefaultThrowableRenderer and HTMLLayout: assert that frame text and cell content are HTML-escaped. Before this, dropping either escapeTags call failed no test. - LoggerFactory: a cache hit must not create a logger in the factory's context. Before this, removing the cache short-circuit failed no test. - LoggerServiceProvider/StaticLoggerBinder: assert that stderr holds no "Failed to" report instead of asserting it is empty. On JDK 21 (in CI's matrix), Mockito's self-attach warning and the JVM's dynamic-agent warning land in the captured stderr when that test is the first Mockito user in its JVM, e.g. when it is run alone. - StaticMarkerBinder: detach the marker the test adds to the JVM-wide factory. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../classic/html/DefaultThrowableRendererTest.java | 11 +++++++++++ .../qos/logback/classic/html/HTMLLayoutJvmTest.java | 11 +++++++++++ .../test/java/org/slf4j/impl/LoggerFactoryTest.java | 10 ++++++++++ .../org/slf4j/impl/LoggerServiceProviderTest.java | 5 ++++- .../java/org/slf4j/impl/StaticLoggerBinderTest.java | 5 ++++- .../java/org/slf4j/impl/StaticMarkerBinderTest.java | 11 ++++++++--- 6 files changed, 48 insertions(+), 5 deletions(-) diff --git a/logback-android/src/test/java/ch/qos/logback/classic/html/DefaultThrowableRendererTest.java b/logback-android/src/test/java/ch/qos/logback/classic/html/DefaultThrowableRendererTest.java index a7db61e16..65bcb9ade 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/html/DefaultThrowableRendererTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/html/DefaultThrowableRendererTest.java @@ -57,6 +57,17 @@ public void throwableSharingFramesIsMarkedAsCauseAndSummarizesTheOmittedFrames() + TRACE_PREFIX + "\t... 2 common frames omitted" + LINE_SEPARATOR, sb.toString()); } + @Test + public void frameTextIsHtmlEscaped() { + DummyThrowableProxy tp = proxy("a.Failure", "ctor", 0, frame("a.B", "", 7)); + + StringBuilder sb = new StringBuilder(); + renderer.render(sb, tp); + + assertEquals("a.Failure: ctor" + LINE_SEPARATOR + + TRACE_PREFIX + "at a.B.<init>(f7:7)" + LINE_SEPARATOR, sb.toString()); + } + @Test public void printFirstLinePrefixesCausedByOnlyWhenFramesAreShared() { StringBuilder top = new StringBuilder(); diff --git a/logback-android/src/test/java/ch/qos/logback/classic/html/HTMLLayoutJvmTest.java b/logback-android/src/test/java/ch/qos/logback/classic/html/HTMLLayoutJvmTest.java index a301d9b49..5c889801a 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/html/HTMLLayoutJvmTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/html/HTMLLayoutJvmTest.java @@ -94,6 +94,17 @@ public void consecutiveRowsAlternateBetweenEvenAndOdd() { assertTrue(layout.doLayout(event(Level.ERROR, "three")).contains("")); } + @Test + public void cellContentIsHtmlEscaped() { + layout.setPattern("%msg"); + layout.start(); + + String row = layout.doLayout(event(Level.INFO, "\"x\" & 'y'")); + + assertTrue(row, row.contains("" + + "<b>"x" & 'y'</b>")); + } + @Test public void mdcColumnIsNamedAfterItsKeyOrMdcWhenNoKeyIsGiven() { layout.setPattern("%mdc{user}%mdc"); diff --git a/logback-android/src/test/java/org/slf4j/impl/LoggerFactoryTest.java b/logback-android/src/test/java/org/slf4j/impl/LoggerFactoryTest.java index 63299a933..d153f6f32 100644 --- a/logback-android/src/test/java/org/slf4j/impl/LoggerFactoryTest.java +++ b/logback-android/src/test/java/org/slf4j/impl/LoggerFactoryTest.java @@ -17,6 +17,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; @@ -47,6 +48,15 @@ public void returnsTheCachedLoggerForARepeatedName() { assertSame(first, factory.loggerMap.get("repeated")); } + @Test + public void cachedLoggerIsReturnedWithoutCreatingOneInTheContext() { + Logger cached = new LoggerFactory().getLogger("preset"); + factory.loggerMap.put("preset", cached); + + assertSame(cached, factory.getLogger("preset")); + assertNull(factory.loggerContext.exists("preset")); + } + @Test public void distinctNamesGetDistinctLoggers() { assertNotSame(factory.getLogger("one"), factory.getLogger("two")); diff --git a/logback-android/src/test/java/org/slf4j/impl/LoggerServiceProviderTest.java b/logback-android/src/test/java/org/slf4j/impl/LoggerServiceProviderTest.java index 256d6af0f..65c594026 100644 --- a/logback-android/src/test/java/org/slf4j/impl/LoggerServiceProviderTest.java +++ b/logback-android/src/test/java/org/slf4j/impl/LoggerServiceProviderTest.java @@ -16,6 +16,7 @@ package org.slf4j.impl; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.doAnswer; @@ -85,7 +86,9 @@ public void initializeAutoConfiguresAndStartsTheDefaultContextAndCreatesFactorie assertDefaultContextStartedAndFactoriesCreated(); assertEquals(1, autoConfiguredContexts.size()); assertSame(provider.getLoggerFactory(), autoConfiguredContexts.get(0)); - assertEquals("", stderr.toString("UTF-8")); + // not assertEquals("", ...): the JVM and Mockito may print their own warnings to stderr + String err = stderr.toString("UTF-8"); + assertFalse(err, err.contains("Failed to")); } @Test diff --git a/logback-android/src/test/java/org/slf4j/impl/StaticLoggerBinderTest.java b/logback-android/src/test/java/org/slf4j/impl/StaticLoggerBinderTest.java index 80f5cbe07..163aede71 100644 --- a/logback-android/src/test/java/org/slf4j/impl/StaticLoggerBinderTest.java +++ b/logback-android/src/test/java/org/slf4j/impl/StaticLoggerBinderTest.java @@ -16,6 +16,7 @@ package org.slf4j.impl; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertSame; @@ -145,7 +146,9 @@ public void initializedBinderHandsOutTheContextOfTheSelector() throws Exception ArgumentCaptor defaultContext = ArgumentCaptor.forClass(LoggerContext.class); verify(selectorBinder).init(defaultContext.capture(), notNull()); assertEquals(CoreConstants.DEFAULT_CONTEXT_NAME, defaultContext.getValue().getName()); - assertEquals("", stderr.toString("UTF-8")); + // not assertEquals("", ...): the JVM and Mockito may print their own warnings to stderr + String err = stderr.toString("UTF-8"); + assertFalse(err, err.contains("Failed to")); } @Test diff --git a/logback-android/src/test/java/org/slf4j/impl/StaticMarkerBinderTest.java b/logback-android/src/test/java/org/slf4j/impl/StaticMarkerBinderTest.java index dc34312c5..0baad3aef 100644 --- a/logback-android/src/test/java/org/slf4j/impl/StaticMarkerBinderTest.java +++ b/logback-android/src/test/java/org/slf4j/impl/StaticMarkerBinderTest.java @@ -39,9 +39,14 @@ public void markerFactoryIsOneSharedBasicMarkerFactory() { assertTrue(factory instanceof BasicMarkerFactory); assertSame(factory, StaticMarkerBinder.getSingleton().getMarkerFactory()); - Marker marker = factory.getMarker("STATIC_MARKER_BINDER_TEST"); - assertSame(marker, StaticMarkerBinder.getSingleton().getMarkerFactory() - .getMarker("STATIC_MARKER_BINDER_TEST")); + try { + Marker marker = factory.getMarker("STATIC_MARKER_BINDER_TEST"); + assertSame(marker, StaticMarkerBinder.getSingleton().getMarkerFactory() + .getMarker("STATIC_MARKER_BINDER_TEST")); + } finally { + // the factory is JVM-wide: leave no marker behind + factory.detachMarker("STATIC_MARKER_BINDER_TEST"); + } } @Test From 7ffe2dcc4de722168edf8bf4147fd63c39135a87 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:10:33 +0000 Subject: [PATCH 52/79] refactor: drop unreachable branches in joran.util introspection These branches can never run, so no test can cover them; each change keeps the behavior for every input: - Introspector.getPropertyDescriptors: the `else if (isGet)` sits inside `if (isGet || isSet)` after `if (isSet)`, so it is always true; it becomes a plain `else`. - PropertySetter.computeAggregationType: computeRawAggregationType() only returns NOT_FOUND, AS_BASIC_PROPERTY or AS_COMPLEX_PROPERTY, so the *_COLLECTION case (which only logged "Unexpected AggregationType") and the implicit default were dead; AS_COMPLEX_PROPERTY becomes the default. - PropertySetter.isUnequivocallyInstantiable: Constructor.newInstance() returns a new instance or throws, never null, so the null check goes. - StringToObjectConverter.getValueOfMethod: Class.getMethod() throws SecurityException only under a SecurityManager, which Android ignores and JDK 18+ refuses to install; the two identical catch blocks become one multi-catch. IntrospectorTest, StringToObjectConverterTest and PropertySetterTest pin the affected methods (getter/setter detection, aggregation types, concrete-type instantiation, valueOf lookup). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../logback/core/joran/util/Introspector.java | 2 +- .../core/joran/util/PropertySetter.java | 18 +- .../joran/util/StringToObjectConverter.java | 4 +- .../core/joran/util/IntrospectorTest.java | 147 ++++++ .../core/joran/util/PropertySetterTest.java | 434 +++++++++++++++++- .../util/StringToObjectConverterTest.java | 180 ++++++++ 6 files changed, 763 insertions(+), 22 deletions(-) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/util/IntrospectorTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/util/StringToObjectConverterTest.java diff --git a/logback-android/src/main/java/ch/qos/logback/core/joran/util/Introspector.java b/logback-android/src/main/java/ch/qos/logback/core/joran/util/Introspector.java index 44d34f8d7..8155cda89 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/joran/util/Introspector.java +++ b/logback-android/src/main/java/ch/qos/logback/core/joran/util/Introspector.java @@ -100,7 +100,7 @@ static public PropertyDescriptor[] getPropertyDescriptors(Class clazz) { pd.setWriteMethod(m); pd.setPropertyType(parmTypes[0]); } - } else if (isGet) { + } else { // isGet if (parmTypes.length == 0) { // we only want the zero-parm getter pd.setReadMethod(m); diff --git a/logback-android/src/main/java/ch/qos/logback/core/joran/util/PropertySetter.java b/logback-android/src/main/java/ch/qos/logback/core/joran/util/PropertySetter.java index 2b8b4759d..7b64c6a88 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/joran/util/PropertySetter.java +++ b/logback-android/src/main/java/ch/qos/logback/core/joran/util/PropertySetter.java @@ -182,12 +182,10 @@ public AggregationType computeAggregationType(String name) { return AggregationType.NOT_FOUND; case AS_BASIC_PROPERTY: return AggregationType.AS_BASIC_PROPERTY_COLLECTION; - case AS_COMPLEX_PROPERTY: + default: + // AS_COMPLEX_PROPERTY: computeRawAggregationType() never returns a + // *_COLLECTION type return AggregationType.AS_COMPLEX_PROPERTY_COLLECTION; - case AS_BASIC_PROPERTY_COLLECTION: - case AS_COMPLEX_PROPERTY_COLLECTION: - addError("Unexpected AggregationType " + type); - break; } } @@ -253,14 +251,10 @@ private boolean isUnequivocallyInstantiable(Class clazz) { // checking for constructors would be more elegant, but in // classes without any declared constructors, Class.getConstructor() // returns null. - Object o; try { - o = clazz.getDeclaredConstructor().newInstance(); - if (o != null) { - return true; - } else { - return false; - } + // newInstance() either returns a non-null instance or throws + clazz.getDeclaredConstructor().newInstance(); + return true; } catch (InstantiationException e) { return false; } catch (IllegalAccessException e) { diff --git a/logback-android/src/main/java/ch/qos/logback/core/joran/util/StringToObjectConverter.java b/logback-android/src/main/java/ch/qos/logback/core/joran/util/StringToObjectConverter.java index 6f462b87d..e3912e38f 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/joran/util/StringToObjectConverter.java +++ b/logback-android/src/main/java/ch/qos/logback/core/joran/util/StringToObjectConverter.java @@ -105,9 +105,7 @@ static private Charset convertToCharset(ContextAware ca, String val) { public static Method getValueOfMethod(Class type) { try { return type.getMethod(CoreConstants.VALUE_OF, STRING_CLASS_PARAMETER); - } catch (NoSuchMethodException e) { - return null; - } catch (SecurityException e) { + } catch (NoSuchMethodException | SecurityException e) { return null; } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/util/IntrospectorTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/util/IntrospectorTest.java new file mode 100644 index 000000000..6d02474e3 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/util/IntrospectorTest.java @@ -0,0 +1,147 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; + +import org.junit.Test; + +public class IntrospectorTest { + + public static class Bean { + public String get() { + return null; + } + + public void set(String value) { + } + + public String getName() { + return null; + } + + public void setName(String name) { + } + + public Object getValue() { + return null; + } + + public void setValue(String value) { + } + + public int getCount() { + return 0; + } + + public void setPair(String first, String second) { + } + + public String getItem(int index) { + return null; + } + } + + private static Map descriptorsOf(Class clazz) { + Map map = new HashMap(); + for (PropertyDescriptor pd : Introspector.getPropertyDescriptors(clazz)) { + map.put(pd.getName(), pd); + } + return map; + } + + @Test + public void canBeInstantiated() { + assertNotNull(new Introspector()); + } + + @Test + public void decapitalizeLowersOnlyTheFirstLetter() { + assertNull(Introspector.decapitalize(null)); + assertEquals("", Introspector.decapitalize("")); + assertEquals("x", Introspector.decapitalize("X")); + assertEquals("fooBar", Introspector.decapitalize("FooBar")); + } + + @Test + public void bareGetAndSetMethodsAreNotProperties() { + Map map = descriptorsOf(Bean.class); + // "class" comes from Object.getClass() + assertEquals(new HashSet(Arrays.asList("class", "name", "value", "count", "pair", "item")), + map.keySet()); + } + + @Test + public void getterAndSetterMakeAReadWriteProperty() throws Exception { + PropertyDescriptor pd = descriptorsOf(Bean.class).get("name"); + assertEquals(Bean.class.getMethod("getName"), pd.getReadMethod()); + assertEquals(Bean.class.getMethod("setName", String.class), pd.getWriteMethod()); + assertEquals(String.class, pd.getPropertyType()); + } + + @Test + public void setterTypeTakesPriorityOverGetterType() throws Exception { + PropertyDescriptor pd = descriptorsOf(Bean.class).get("value"); + assertEquals(Bean.class.getMethod("getValue"), pd.getReadMethod()); + assertEquals(String.class, pd.getPropertyType()); + } + + @Test + public void getterAloneMakesAReadOnlyProperty() throws Exception { + PropertyDescriptor pd = descriptorsOf(Bean.class).get("count"); + assertEquals(Bean.class.getMethod("getCount"), pd.getReadMethod()); + assertNull(pd.getWriteMethod()); + assertEquals(int.class, pd.getPropertyType()); + } + + @Test + public void setterWithTwoParametersIsIgnored() { + PropertyDescriptor pd = descriptorsOf(Bean.class).get("pair"); + assertNull(pd.getWriteMethod()); + assertNull(pd.getReadMethod()); + assertNull(pd.getPropertyType()); + } + + @Test + public void getterWithParameterIsIgnored() { + PropertyDescriptor pd = descriptorsOf(Bean.class).get("item"); + assertNull(pd.getReadMethod()); + assertNull(pd.getWriteMethod()); + assertNull(pd.getPropertyType()); + } + + @Test + public void methodDescriptorsListThePublicMethods() throws Exception { + MethodDescriptor[] descriptors = Introspector.getMethodDescriptors(Bean.class); + assertEquals(Bean.class.getMethods().length, descriptors.length); + boolean found = false; + for (MethodDescriptor md : descriptors) { + assertEquals(md.getName(), md.getMethod().getName()); + if (md.getMethod().equals(Bean.class.getMethod("setPair", String.class, String.class))) { + found = true; + } + } + assertTrue(found); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/util/PropertySetterTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/util/PropertySetterTest.java index a4e5e4ab2..f29ed7ba8 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/util/PropertySetterTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/util/PropertySetterTest.java @@ -15,24 +15,36 @@ */ package ch.qos.logback.core.joran.util; -import static junit.framework.Assert.assertEquals; -import static junit.framework.Assert.assertNotNull; -import static junit.framework.Assert.assertNull; -import static junit.framework.Assert.assertTrue; - +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mockStatic; + +import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.nio.charset.Charset; import java.nio.charset.UnsupportedCharsetException; +import java.util.ArrayList; +import java.util.List; import org.junit.Before; import org.junit.Test; +import org.mockito.MockedStatic; import ch.qos.logback.core.Context; import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.joran.spi.DefaultClass; import ch.qos.logback.core.joran.spi.DefaultNestedComponentRegistry; import ch.qos.logback.core.spi.FilterReply; +import ch.qos.logback.core.status.Status; import ch.qos.logback.core.status.StatusChecker; import ch.qos.logback.core.util.AggregationType; +import ch.qos.logback.core.util.PropertySetterException; public class PropertySetterTest { @@ -243,7 +255,417 @@ public void charset() { assertNull(house.getCharset()); StatusChecker checker = new StatusChecker(context); - checker.containsException(UnsupportedCharsetException.class); + checker.asssertContainsException(UnsupportedCharsetException.class); + } + + // --------------------------------------------------------------------------- + // Fixtures for the tests below. Every method name is unique, so that the + // name-based lookups of PropertySetter are unambiguous. + + public static class Gizmo { + String label; + String readOnly = "fixed"; + int size; + final List things = new ArrayList(); + final List numbers = new ArrayList(); + final List hooks = new ArrayList(); + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getReadOnly() { + return readOnly; + } + + public int getSize() { + return size; + } + + public void setSize(int size) { + this.size = size; + } + + public void setExploding(String value) { + throw new IllegalStateException("exploding setter"); + } + + // an adder without parameter, next to a usable setter of the same name + public void addHook() { + hooks.add("adder"); + } + + public void setHook(String hook) { + hooks.add(hook); + } + + public void addPair(String first, String second) { + things.add(first + second); + } + + public void addThing(Object thing) { + things.add(thing); + } + + public void addNumber(int number) { + numbers.add(number); + } + + public void addFailure(String value) { + throw new IllegalStateException("failing adder"); + } + + public void setPart(Part part) { + } + + public void setAbstractPart(AbstractPart part) { + } + + public void setHiddenPart(HiddenPart part) { + } + + public void setArgPart(ArgPart part) { + } + + public void setFaultyPart(FaultyPart part) { + } + + public void setConcretePart(ConcretePart part) { + } + } + + public interface Part { + } + + public static abstract class AbstractPart { + public AbstractPart() { + } + } + + public static class HiddenPart { + private HiddenPart() { + } + } + + public static class ArgPart { + public ArgPart(String arg) { + } + } + + public static class FaultyPart { + public FaultyPart() { + throw new IllegalStateException("faulty constructor"); + } + } + + public static class ConcretePart { + } + + private static final String GIZMO = Gizmo.class.getName(); + + Gizmo gizmo = new Gizmo(); + + private PropertySetter gizmoSetter() { + PropertySetter ps = new PropertySetter(gizmo); + ps.setContext(context); + return ps; + } + + private static Method gizmoMethod(String name, Class... parameterTypes) throws NoSuchMethodException { + return Gizmo.class.getMethod(name, parameterTypes); + } + + private List statuses() { + return context.getStatusManager().getCopyOfStatusList(); + } + + private Status statusWithMessage(String message) { + for (Status s : statuses()) { + if (message.equals(s.getMessage())) { + return s; + } + } + fail("no status with message [" + message + "] in " + statuses()); + return null; + } + + private void assertNoStatusStartingWith(String prefix) { + for (Status s : statuses()) { + if (s.getMessage().startsWith(prefix)) { + fail("unexpected status " + s); + } + } + } + + @Test + public void setPropertyIgnoresNullValue() { + gizmo.label = "before"; + gizmoSetter().setProperty("label", null); + assertEquals("before", gizmo.label); + assertTrue(statuses().isEmpty()); + } + + @Test + public void setPropertyWarnsAboutUnknownProperty() { + gizmoSetter().setProperty("NoSuchThing", "x"); + Status s = statusWithMessage("No such property [noSuchThing] in " + GIZMO + "."); + assertEquals(Status.WARN, s.getLevel()); + assertEquals(1, statuses().size()); + } + + @Test + public void setPropertyWarnsWhenPropertyHasNoSetter() { + gizmoSetter().setProperty("readOnly", "x"); + assertEquals("fixed", gizmo.readOnly); + Status s = statusWithMessage("Failed to set property [readOnly] to value \"x\". "); + assertEquals(Status.WARN, s.getLevel()); + assertTrue(s.getThrowable() instanceof PropertySetterException); + assertEquals("No setter for property [readOnly].", s.getThrowable().getMessage()); + } + + @Test + public void setPropertyWarnsWhenValueCannotBeConverted() { + gizmoSetter().setProperty("size", "abc"); + assertEquals(0, gizmo.size); + Status s = statusWithMessage("Failed to set property [size] to value \"abc\". "); + assertEquals(Status.WARN, s.getLevel()); + Throwable t = s.getThrowable(); + assertTrue(t instanceof PropertySetterException); + assertEquals("Conversion to type [int] failed. ", t.getMessage()); + assertTrue(t.getCause() instanceof NumberFormatException); + } + + @Test + public void setPropertyWarnsWhenSetterThrows() { + gizmoSetter().setProperty("exploding", "x"); + Status s = statusWithMessage("Failed to set property [exploding] to value \"x\". "); + assertEquals(Status.WARN, s.getLevel()); + Throwable t = s.getThrowable(); + assertTrue(t instanceof PropertySetterException); + assertTrue(t.getCause() instanceof InvocationTargetException); + assertEquals("exploding setter", t.getCause().getCause().getMessage()); + } + + @Test + public void setPropertyRejectsWriteMethodWithoutExactlyOneParameter() throws Exception { + final PropertySetter ps = gizmoSetter(); + final PropertyDescriptor pd = new PropertyDescriptor("pair"); + pd.setWriteMethod(gizmoMethod("addPair", String.class, String.class)); + PropertySetterException e = assertThrows(PropertySetterException.class, + () -> ps.setProperty(pd, "pair", "x")); + assertEquals("#params for setter != 1", e.getMessage()); + assertTrue(gizmo.things.isEmpty()); + } + + @Test + public void computeAggregationTypeIsNotFoundForAdderWithoutParameter() { + // the parameterless addHook() takes precedence over the usable setHook(String) + assertEquals(AggregationType.NOT_FOUND, gizmoSetter().computeAggregationType("hook")); + assertEquals(AggregationType.AS_BASIC_PROPERTY, gizmoSetter().computeAggregationType("label")); + } + + @Test + public void getObjAndGetObjClassDescribeTheTarget() { + PropertySetter ps = gizmoSetter(); + assertSame(gizmo, ps.getObj()); + assertSame(Gizmo.class, ps.getObjClass()); + } + + @Test + public void addComplexPropertyReportsMissingAdder() { + gizmoSetter().addComplexProperty("missing", new Object()); + Status s = statusWithMessage("Could not find method [addmissing] in class [" + GIZMO + "]."); + assertEquals(Status.ERROR, s.getLevel()); + assertEquals(1, statuses().size()); + } + + @Test + public void addComplexPropertyRejectsComponentOfWrongType() { + setter.addComplexProperty("window", new Door()); + assertTrue(house.windowList.isEmpty()); + List list = statuses(); + assertEquals(4, list.size()); + assertEquals("A \"" + Door.class.getName() + "\" object is not assignable to a \"" + + Window.class.getName() + "\" variable.", list.get(0).getMessage()); + assertEquals("The class \"" + Window.class.getName() + "\" was loaded by ", list.get(1).getMessage()); + assertEquals("[" + Window.class.getClassLoader() + "] whereas object of type ", list.get(2).getMessage()); + assertEquals("\"" + Door.class.getName() + "\" was loaded by [" + Door.class.getClassLoader() + "].", + list.get(3).getMessage()); + for (Status s : list) { + assertEquals(Status.ERROR, s.getLevel()); + } + } + + @Test + public void addComplexPropertyRejectsAdderWithWrongParameterCount() { + gizmoSetter().addComplexProperty("pair", "x"); + assertTrue(gizmo.things.isEmpty()); + Status s = statusWithMessage("Wrong number of parameters in setter method for property [pair] in " + GIZMO); + assertEquals(Status.ERROR, s.getLevel()); + assertEquals(1, statuses().size()); + } + + @Test + public void addComplexPropertyReportsExceptionThrownByAdder() { + gizmoSetter().addComplexProperty("failure", "x"); + Status s = statusWithMessage("Could not invoke method addFailure in class " + GIZMO + + " with parameter of type java.lang.String"); + assertEquals(Status.ERROR, s.getLevel()); + assertTrue(s.getThrowable() instanceof InvocationTargetException); + assertEquals("failing adder", s.getThrowable().getCause().getMessage()); + } + + @Test + public void addBasicPropertyIgnoresNullValue() { + setter.addBasicProperty("adjective", null); + assertTrue(house.adjectiveList.isEmpty()); + assertTrue(statuses().isEmpty()); + } + + @Test + public void addBasicPropertyReportsMissingAdder() { + gizmoSetter().addBasicProperty("missing", "x"); + Status s = statusWithMessage("No adder for property [Missing]."); + assertEquals(Status.ERROR, s.getLevel()); + assertEquals(1, statuses().size()); + } + + @Test + public void addBasicPropertyReportsConversionFailure() { + gizmoSetter().addBasicProperty("number", "abc"); + assertTrue(gizmo.numbers.isEmpty()); + Status s = statusWithMessage("Conversion to type [int] failed. "); + assertEquals(Status.ERROR, s.getLevel()); + assertTrue(s.getThrowable() instanceof NumberFormatException); + assertNoStatusStartingWith("Could not invoke"); + } + + @Test + public void addBasicPropertySkipsAdderWhenValueCannotBeConverted() { + // there is no conversion from String to Object, so addThing(Object) is not called + gizmoSetter().addBasicProperty("thing", "x"); + assertTrue(gizmo.things.isEmpty()); + assertTrue(statuses().isEmpty()); + } + + @Test + public void setComplexPropertyWarnsAboutUnknownProperty() { + gizmoSetter().setComplexProperty("missing", new Object()); + Status s = statusWithMessage("Could not find PropertyDescriptor for [missing] in " + GIZMO); + assertEquals(Status.WARN, s.getLevel()); + assertEquals(1, statuses().size()); + } + + @Test + public void setComplexPropertyWarnsWhenPropertyHasNoSetter() { + gizmoSetter().setComplexProperty("readOnly", "x"); + assertEquals("fixed", gizmo.readOnly); + Status s = statusWithMessage("Not setter method for property [readOnly] in " + GIZMO); + assertEquals(Status.WARN, s.getLevel()); + assertEquals(1, statuses().size()); + } + + @Test + public void setComplexPropertyRejectsComponentOfWrongType() { + setter.setComplexProperty("door", new Window()); + assertNull(house.getDoor()); + List list = statuses(); + assertEquals(4, list.size()); + assertEquals("A \"" + Window.class.getName() + "\" object is not assignable to a \"" + + Door.class.getName() + "\" variable.", list.get(0).getMessage()); + } + + @Test + public void setComplexPropertyReportsFailureToInvokeSetter() { + final RuntimeException failure = new RuntimeException("invocation failed"); + PropertySetter ps = new PropertySetter(house) { + @Override + void invokeMethodWithSingleParameterOnThisObject(Method method, Object parameter) { + throw failure; + } + }; + ps.setContext(context); + ps.setComplexProperty("door", new Door()); + assertNull(house.getDoor()); + Status s = statusWithMessage("Could not set component " + house + " for parent component " + house); + assertEquals(Status.ERROR, s.getLevel()); + assertSame(failure, s.getThrowable()); + } + + @Test + public void getRelevantMethodRejectsNonComplexAggregationType() { + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> setter.getRelevantMethod("name", AggregationType.AS_BASIC_PROPERTY)); + assertEquals("AS_BASIC_PROPERTY not allowed here", e.getMessage()); + } + + @Test + public void annotationOfMissingMethodIsNull() { + assertNull(setter.getAnnotation(DefaultClass.class, null)); + assertNull(setter.getDefaultClassNameByAnnonation("swimmingPool", null)); + } + + @Test + public void getByConcreteTypeIsNullWithoutSingleParameter() throws Exception { + PropertySetter ps = gizmoSetter(); + assertNull(ps.getByConcreteType(null)); + assertNull(ps.getByConcreteType(gizmoMethod("addHook"))); + assertNull(ps.getByConcreteType(gizmoMethod("addPair", String.class, String.class))); + } + + @Test + public void getByConcreteTypeIsNullForParameterTypeThatCannotBeInstantiated() throws Exception { + PropertySetter ps = gizmoSetter(); + assertNull(ps.getByConcreteType(gizmoMethod("setPart", Part.class))); + assertNull(ps.getByConcreteType(gizmoMethod("setAbstractPart", AbstractPart.class))); + assertNull(ps.getByConcreteType(gizmoMethod("setHiddenPart", HiddenPart.class))); + assertNull(ps.getByConcreteType(gizmoMethod("setArgPart", ArgPart.class))); + assertNull(ps.getByConcreteType(gizmoMethod("setFaultyPart", FaultyPart.class))); + assertEquals(ConcretePart.class, ps.getByConcreteType(gizmoMethod("setConcretePart", ConcretePart.class))); + } + + @Test + public void getClassNameViaImplicitRulesIsNullWhenNoRuleApplies() { + PropertySetter ps = gizmoSetter(); + // no such property + assertNull(ps.getClassNameViaImplicitRules("missing", AggregationType.AS_COMPLEX_PROPERTY, + defaultComponentRegistry)); + // an interface, without @DefaultClass + assertNull(ps.getClassNameViaImplicitRules("part", AggregationType.AS_COMPLEX_PROPERTY, + defaultComponentRegistry)); + assertEquals(ConcretePart.class, ps.getClassNameViaImplicitRules("concretePart", + AggregationType.AS_COMPLEX_PROPERTY, defaultComponentRegistry)); + } + + @Test + public void getClassNameViaImplicitRulesPrefersRegisteredDefault() { + DefaultNestedComponentRegistry registry = new DefaultNestedComponentRegistry(); + registry.add(Gizmo.class, "part", ConcretePart.class); + assertEquals(ConcretePart.class, gizmoSetter().getClassNameViaImplicitRules("part", + AggregationType.AS_COMPLEX_PROPERTY, registry)); + } + + @Test + public void failedIntrospectionIsReportedAndLeavesNoProperties() { + try (MockedStatic introspector = mockStatic(Introspector.class, CALLS_REAL_METHODS)) { + introspector.when(() -> Introspector.getPropertyDescriptors(Gizmo.class)) + .thenThrow(new IntrospectionException("cannot introspect")); + + PropertySetter ps = gizmoSetter(); + ps.setProperty("label", "x"); + // the method descriptors were emptied too: addThing(Object) is not found + assertEquals(AggregationType.NOT_FOUND, ps.computeAggregationType("thing")); + } + assertNull(gizmo.label); + Status error = statusWithMessage("Failed to introspect " + gizmo + ": cannot introspect"); + assertEquals(Status.ERROR, error.getLevel()); + Status warn = statusWithMessage("No such property [label] in " + GIZMO + "."); + assertEquals(Status.WARN, warn.getLevel()); + assertEquals(2, statuses().size()); } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/util/StringToObjectConverterTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/util/StringToObjectConverterTest.java new file mode 100644 index 000000000..0abb86695 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/util/StringToObjectConverterTest.java @@ -0,0 +1,180 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mockStatic; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.nio.charset.Charset; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.spi.ContextAwareBase; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.util.Duration; + +public class StringToObjectConverterTest { + + /** Has a valueOf(String) method, but not a static one. */ + public static class InstanceValueOf { + public InstanceValueOf valueOf(String s) { + return this; + } + } + + Context context = new ContextBase(); + ContextAwareBase ca = new ContextAwareBase(); + + @Before + public void setUp() { + ca.setContext(context); + } + + private List statuses() { + return context.getStatusManager().getCopyOfStatusList(); + } + + @Test + public void canBeInstantiated() { + assertFalse(new StringToObjectConverter().isBuildableFromSimpleString()); + } + + @Test + public void primitivesAndJavaLangTypesCanBeBuiltFromSimpleString() { + assertTrue(StringToObjectConverter.canBeBuiltFromSimpleString(int.class)); + assertTrue(StringToObjectConverter.canBeBuiltFromSimpleString(Integer.class)); + assertTrue(StringToObjectConverter.canBeBuiltFromSimpleString(String.class)); + } + + @Test + public void typesWithStaticValueOfMethodCanBeBuiltFromSimpleString() { + assertTrue(StringToObjectConverter.canBeBuiltFromSimpleString(Duration.class)); + assertFalse(StringToObjectConverter.canBeBuiltFromSimpleString(InstanceValueOf.class)); + } + + @Test + public void charsetCanBeBuiltFromSimpleString() { + assertTrue(StringToObjectConverter.canBeBuiltFromSimpleString(Charset.class)); + } + + @Test + public void otherTypesCannotBeBuiltFromSimpleString() { + assertFalse(StringToObjectConverter.canBeBuiltFromSimpleString(Door.class)); + // array classes have no package + assertFalse(StringToObjectConverter.canBeBuiltFromSimpleString(String[].class)); + } + + @Test + public void enumCanBeBuiltFromSimpleStringEvenWithoutValueOfMethod() { + // every enum has a static valueOf(String) method, so the enum rule only + // applies when that method cannot be found + try (MockedStatic converter = mockStatic(StringToObjectConverter.class, + CALLS_REAL_METHODS)) { + converter.when(() -> StringToObjectConverter.getValueOfMethod(HouseColor.class)).thenReturn(null); + assertNull(StringToObjectConverter.getValueOfMethod(HouseColor.class)); + + assertTrue(StringToObjectConverter.canBeBuiltFromSimpleString(HouseColor.class)); + } + } + + @Test + public void getValueOfMethodFindsPublicValueOfTakingString() throws Exception { + Method m = StringToObjectConverter.getValueOfMethod(Integer.class); + assertNotNull(m); + assertEquals(Integer.class.getMethod("valueOf", String.class), m); + assertTrue(Modifier.isStatic(m.getModifiers())); + assertNull(StringToObjectConverter.getValueOfMethod(Door.class)); + } + + @Test + public void nullValueConvertsToNull() { + assertNull(StringToObjectConverter.convertArg(ca, null, String.class)); + } + + @Test + public void stringValueIsTrimmed() { + assertEquals("abc", StringToObjectConverter.convertArg(ca, " abc ", String.class)); + } + + @Test + public void primitiveNumbersAreParsed() { + assertEquals(Integer.valueOf(42), StringToObjectConverter.convertArg(ca, " 42 ", int.class)); + assertEquals(Long.valueOf(42L), StringToObjectConverter.convertArg(ca, "42", long.class)); + assertEquals(Float.valueOf(1.5f), StringToObjectConverter.convertArg(ca, "1.5", float.class)); + assertEquals(Double.valueOf(2.5d), StringToObjectConverter.convertArg(ca, "2.5", double.class)); + } + + @Test + public void booleanIsParsedIgnoringCase() { + assertEquals(Boolean.TRUE, StringToObjectConverter.convertArg(ca, "TRUE", boolean.class)); + assertEquals(Boolean.FALSE, StringToObjectConverter.convertArg(ca, "False", boolean.class)); + assertNull(StringToObjectConverter.convertArg(ca, "maybe", boolean.class)); + } + + @Test + public void enumIsConvertedByConstantName() { + assertEquals(HouseColor.BLUE, StringToObjectConverter.convertArg(ca, "BLUE", HouseColor.class)); + } + + @Test + public void staticValueOfMethodIsUsed() { + Duration d = (Duration) StringToObjectConverter.convertArg(ca, "2 seconds", Duration.class); + assertEquals(2000L, d.getMilliseconds()); + assertTrue(statuses().isEmpty()); + } + + @Test + public void failingValueOfMethodIsReported() { + assertNull(StringToObjectConverter.convertArg(ca, "abc", Integer.class)); + List list = statuses(); + assertEquals(1, list.size()); + assertEquals(Status.ERROR, list.get(0).getLevel()); + assertEquals("Failed to invoke valueOf{} method in class [java.lang.Integer] with value [abc]", + list.get(0).getMessage()); + } + + @Test + public void charsetIsLookedUpByName() { + assertEquals(Charset.forName("UTF-8"), StringToObjectConverter.convertArg(ca, "UTF-8", Charset.class)); + } + + @Test + public void unsupportedCharsetIsReported() { + assertNull(StringToObjectConverter.convertArg(ca, "no-such-charset", Charset.class)); + List list = statuses(); + assertEquals(1, list.size()); + assertEquals(Status.ERROR, list.get(0).getLevel()); + assertEquals("Failed to get charset [no-such-charset]", list.get(0).getMessage()); + } + + @Test + public void unsupportedTypeConvertsToNull() { + assertNull(StringToObjectConverter.convertArg(ca, "x", Object.class)); + assertTrue(statuses().isEmpty()); + } +} From 01f221fce6be4bfe69e93202865f30c0d427c1e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:10:39 +0000 Subject: [PATCH 53/79] test: cover ConfigurationWatchListUtil, joran.util beans and property definers - ConfigurationWatchListUtilTest: null context (no list, console message instead of a status), a context without status manager, creating vs clearing the registered watch list, info/warn statuses of addToWatchList. - IntrospectionExceptionTest, PropertyDescriptorTest: constructors and accessors. - FileExistsPropertyDefinerTest, ResourceExistsPropertyDefinerTest: "true"/"false" for present/absent file or class path resource, and the error status plus null value when path/resource is unset or empty. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../util/ConfigurationWatchListUtilTest.java | 150 ++++++++++++++++++ .../util/IntrospectionExceptionTest.java | 40 +++++ .../joran/util/PropertyDescriptorTest.java | 48 ++++++ .../FileExistsPropertyDefinerTest.java | 86 ++++++++++ .../ResourceExistsPropertyDefinerTest.java | 85 ++++++++++ 5 files changed, 409 insertions(+) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/util/ConfigurationWatchListUtilTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/util/IntrospectionExceptionTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/util/PropertyDescriptorTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/property/FileExistsPropertyDefinerTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/property/ResourceExistsPropertyDefinerTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/util/ConfigurationWatchListUtilTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/util/ConfigurationWatchListUtilTest.java new file mode 100644 index 000000000..bdd4f4c58 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/util/ConfigurationWatchListUtilTest.java @@ -0,0 +1,150 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.net.URI; +import java.net.URL; +import java.util.Collections; +import java.util.List; + +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.joran.spi.ConfigurationWatchList; +import ch.qos.logback.core.status.Status; + +public class ConfigurationWatchListUtilTest { + + // the files need not exist: the watch list only records them + static final String MAIN_PATH = "/logback-test/main.xml"; + static final String INCLUDED_PATH = "/logback-test/included.xml"; + + Context context = new ContextBase(); + + private static URL fileURL(String path) throws Exception { + return new URI("file", null, path, null).toURL(); + } + + private List statuses() { + return context.getStatusManager().getCopyOfStatusList(); + } + + @Test + public void nullContextHasNoWatchList() throws Exception { + // must not throw + ConfigurationWatchListUtil.setMainWatchURL(null, fileURL(MAIN_PATH)); + assertNull(ConfigurationWatchListUtil.getConfigurationWatchList(null)); + assertNull(ConfigurationWatchListUtil.getMainWatchURL(null)); + } + + @Test + public void setMainWatchURLCreatesAndRegistersWatchList() throws Exception { + URL main = fileURL(MAIN_PATH); + ConfigurationWatchListUtil.setMainWatchURL(context, main); + + ConfigurationWatchList cwl = ConfigurationWatchListUtil.getConfigurationWatchList(context); + assertSame(cwl, context.getObject(CoreConstants.CONFIGURATION_WATCH_LIST)); + assertSame(context, cwl.getContext()); + assertEquals(main, ConfigurationWatchListUtil.getMainWatchURL(context)); + assertEquals(Collections.singletonList(new File(MAIN_PATH)), cwl.getCopyOfFileWatchList()); + } + + @Test + public void setMainWatchURLClearsExistingWatchList() throws Exception { + ConfigurationWatchList cwl = new ConfigurationWatchList(); + ConfigurationWatchListUtil.registerConfigurationWatchList(context, cwl); + ConfigurationWatchListUtil.addToWatchList(context, fileURL(INCLUDED_PATH)); + + URL main = fileURL(MAIN_PATH); + ConfigurationWatchListUtil.setMainWatchURL(context, main); + + assertSame(cwl, ConfigurationWatchListUtil.getConfigurationWatchList(context)); + assertEquals(main, cwl.getMainURL()); + assertEquals(Collections.singletonList(new File(MAIN_PATH)), cwl.getCopyOfFileWatchList()); + } + + @Test + public void getMainWatchURLIsNullWithoutWatchList() { + assertNull(ConfigurationWatchListUtil.getMainWatchURL(context)); + } + + @Test + public void addToWatchListAddsFileAndReportsIt() throws Exception { + ConfigurationWatchList cwl = new ConfigurationWatchList(); + ConfigurationWatchListUtil.registerConfigurationWatchList(context, cwl); + URL included = fileURL(INCLUDED_PATH); + + ConfigurationWatchListUtil.addToWatchList(context, included); + + assertEquals(Collections.singletonList(new File(INCLUDED_PATH)), cwl.getCopyOfFileWatchList()); + List list = statuses(); + assertEquals(1, list.size()); + assertEquals(Status.INFO, list.get(0).getLevel()); + assertEquals("Adding [" + included + "] to configuration watch list.", list.get(0).getMessage()); + assertSame(ConfigurationWatchListUtil.origin, list.get(0).getOrigin()); + } + + @Test + public void addToWatchListWarnsWithoutWatchList() throws Exception { + URL included = fileURL(INCLUDED_PATH); + ConfigurationWatchListUtil.addToWatchList(context, included); + + List list = statuses(); + assertEquals(1, list.size()); + assertEquals(Status.WARN, list.get(0).getLevel()); + assertEquals("Null ConfigurationWatchList. Cannot add " + included, list.get(0).getMessage()); + } + + @Test + public void addToWatchListWithNullContextPrintsToConsole() throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + PrintStream original = System.out; + System.setOut(new PrintStream(out, true, "UTF-8")); + try { + ConfigurationWatchListUtil.addToWatchList(null, fileURL(INCLUDED_PATH)); + } finally { + System.setOut(original); + } + String printed = out.toString("UTF-8"); + assertTrue(printed, printed.startsWith("Null context in " + ConfigurationWatchList.class.getName())); + } + + @Test + public void addToWatchListWithoutStatusManagerStillAddsFile() throws Exception { + ConfigurationWatchList cwl = new ConfigurationWatchList(); + Context ctx = mock(Context.class); + when(ctx.getObject(CoreConstants.CONFIGURATION_WATCH_LIST)).thenReturn(cwl); + when(ctx.getStatusManager()).thenReturn(null); + + ConfigurationWatchListUtil.addToWatchList(ctx, fileURL(INCLUDED_PATH)); + + verify(ctx).getStatusManager(); + assertEquals(Collections.singletonList(new File(INCLUDED_PATH)), cwl.getCopyOfFileWatchList()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/util/IntrospectionExceptionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/util/IntrospectionExceptionTest.java new file mode 100644 index 000000000..b908824b5 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/util/IntrospectionExceptionTest.java @@ -0,0 +1,40 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +import org.junit.Test; + +public class IntrospectionExceptionTest { + + @Test + public void messageConstructorKeepsMessage() { + IntrospectionException e = new IntrospectionException("no such class"); + assertEquals("no such class", e.getMessage()); + assertNull(e.getCause()); + } + + @Test + public void causeConstructorKeepsCause() { + Exception cause = new ClassNotFoundException("com.example.Missing"); + IntrospectionException e = new IntrospectionException(cause); + assertSame(cause, e.getCause()); + assertEquals(cause.toString(), e.getMessage()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/util/PropertyDescriptorTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/util/PropertyDescriptorTest.java new file mode 100644 index 000000000..a6ed4b89e --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/util/PropertyDescriptorTest.java @@ -0,0 +1,48 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import java.lang.reflect.Method; + +import org.junit.Test; + +public class PropertyDescriptorTest { + + @Test + public void newDescriptorHasOnlyAName() { + PropertyDescriptor pd = new PropertyDescriptor("level"); + assertEquals("level", pd.getName()); + assertNull(pd.getReadMethod()); + assertNull(pd.getWriteMethod()); + assertNull(pd.getPropertyType()); + } + + @Test + public void accessorsRoundTrip() throws Exception { + Method read = StringBuilder.class.getMethod("length"); + Method write = StringBuilder.class.getMethod("setLength", int.class); + PropertyDescriptor pd = new PropertyDescriptor("length"); + pd.setReadMethod(read); + pd.setWriteMethod(write); + pd.setPropertyType(int.class); + assertEquals(read, pd.getReadMethod()); + assertEquals(write, pd.getWriteMethod()); + assertEquals(int.class, pd.getPropertyType()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/property/FileExistsPropertyDefinerTest.java b/logback-android/src/test/java/ch/qos/logback/core/property/FileExistsPropertyDefinerTest.java new file mode 100644 index 000000000..c5bdc92f6 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/property/FileExistsPropertyDefinerTest.java @@ -0,0 +1,86 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.property; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.util.List; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.status.Status; + +public class FileExistsPropertyDefinerTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + Context context = new ContextBase(); + FileExistsPropertyDefiner definer = new FileExistsPropertyDefiner(); + + @Before + public void setUp() { + definer.setContext(context); + } + + private List statuses() { + return context.getStatusManager().getCopyOfStatusList(); + } + + @Test + public void pathRoundTrips() { + assertNull(definer.getPath()); + definer.setPath("some/file.txt"); + assertEquals("some/file.txt", definer.getPath()); + } + + @Test + public void existingFileIsTrue() throws Exception { + File file = tmp.newFile("present.txt"); + definer.setPath(file.getAbsolutePath()); + assertEquals("true", definer.getPropertyValue()); + assertTrue(statuses().isEmpty()); + } + + @Test + public void missingFileIsFalse() { + definer.setPath(new File(tmp.getRoot(), "absent.txt").getAbsolutePath()); + assertEquals("false", definer.getPropertyValue()); + assertTrue(statuses().isEmpty()); + } + + @Test + public void missingPathIsAnError() { + assertNull(definer.getPropertyValue()); + definer.setPath(""); + assertNull(definer.getPropertyValue()); + + List list = statuses(); + assertEquals(2, list.size()); + for (Status s : list) { + assertEquals(Status.ERROR, s.getLevel()); + assertEquals("The \"path\" property must be set.", s.getMessage()); + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/property/ResourceExistsPropertyDefinerTest.java b/logback-android/src/test/java/ch/qos/logback/core/property/ResourceExistsPropertyDefinerTest.java new file mode 100644 index 000000000..96bbe2df1 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/property/ResourceExistsPropertyDefinerTest.java @@ -0,0 +1,85 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.property; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.List; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.status.Status; + +public class ResourceExistsPropertyDefinerTest { + + // a resource of this very library, so that it is on the class path of + // logback's own class loader + static final String PRESENT_RESOURCE = + ResourceExistsPropertyDefiner.class.getName().replace('.', '/') + ".class"; + static final String ABSENT_RESOURCE = "ch/qos/logback/core/property/no-such-resource.xml"; + + Context context = new ContextBase(); + ResourceExistsPropertyDefiner definer = new ResourceExistsPropertyDefiner(); + + @Before + public void setUp() { + definer.setContext(context); + } + + private List statuses() { + return context.getStatusManager().getCopyOfStatusList(); + } + + @Test + public void resourceRoundTrips() { + assertNull(definer.getResource()); + definer.setResource("some/resource.xml"); + assertEquals("some/resource.xml", definer.getResource()); + } + + @Test + public void resourceOnClassPathIsTrue() { + definer.setResource(PRESENT_RESOURCE); + assertEquals("true", definer.getPropertyValue()); + assertTrue(statuses().isEmpty()); + } + + @Test + public void resourceMissingFromClassPathIsFalse() { + definer.setResource(ABSENT_RESOURCE); + assertEquals("false", definer.getPropertyValue()); + assertTrue(statuses().isEmpty()); + } + + @Test + public void missingResourceNameIsAnError() { + assertNull(definer.getPropertyValue()); + definer.setResource(""); + assertNull(definer.getPropertyValue()); + + List list = statuses(); + assertEquals(2, list.size()); + for (Status s : list) { + assertEquals(Status.ERROR, s.getLevel()); + assertEquals("The \"resource\" property must be set.", s.getMessage()); + } + } +} From 401b6bae3aabd5243edc3e4170d63696114ad306 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:31:43 +0000 Subject: [PATCH 54/79] test: pin setter-type priority and null conversions in joran.util Mutation checks found three changes to covered code that no test caught: - PropertySetter.setProperty: dropping the "arg == null" check. A boolean property set to "maybe" must warn "Conversion to type [boolean] failed." and leave the target unchanged. - Introspector.decapitalize: "length() > 1" shifted to "> 2". "AB" must become "aB". - Introspector.getPropertyDescriptors: letting the getter's return type replace the setter's. Whether that happened depended on the order of Class.getMethods() for a single class. Two beans that inherit the getter or the setter now present the accessors in both orders, and the setter's type must win in each. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../core/joran/util/IntrospectorTest.java | 41 +++++++++++++++++++ .../core/joran/util/PropertySetterTest.java | 16 ++++++++ 2 files changed, 57 insertions(+) diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/util/IntrospectorTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/util/IntrospectorTest.java index 6d02474e3..6b028153f 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/util/IntrospectorTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/util/IntrospectorTest.java @@ -63,6 +63,30 @@ public String getItem(int index) { } } + public static class ValueGetter { + public Object getValue() { + return null; + } + } + + /** Declares the setter and inherits the getter. */ + public static class InheritedGetterBean extends ValueGetter { + public void setValue(String value) { + } + } + + public static class ValueSetter { + public void setValue(String value) { + } + } + + /** Declares the getter and inherits the setter. */ + public static class InheritedSetterBean extends ValueSetter { + public Object getValue() { + return null; + } + } + private static Map descriptorsOf(Class clazz) { Map map = new HashMap(); for (PropertyDescriptor pd : Introspector.getPropertyDescriptors(clazz)) { @@ -81,6 +105,7 @@ public void decapitalizeLowersOnlyTheFirstLetter() { assertNull(Introspector.decapitalize(null)); assertEquals("", Introspector.decapitalize("")); assertEquals("x", Introspector.decapitalize("X")); + assertEquals("aB", Introspector.decapitalize("AB")); assertEquals("fooBar", Introspector.decapitalize("FooBar")); } @@ -107,6 +132,22 @@ public void setterTypeTakesPriorityOverGetterType() throws Exception { assertEquals(String.class, pd.getPropertyType()); } + @Test + public void setterTypeTakesPriorityWhicheverAccessorIsVisitedFirst() throws Exception { + // Class.getMethods() usually lists a class's own methods before the + // inherited ones, so these two beans present the setter and the getter of + // "value" in opposite orders. The setter's type must win either way. + PropertyDescriptor setterFirst = descriptorsOf(InheritedGetterBean.class).get("value"); + assertEquals(ValueGetter.class.getMethod("getValue"), setterFirst.getReadMethod()); + assertEquals(InheritedGetterBean.class.getMethod("setValue", String.class), setterFirst.getWriteMethod()); + assertEquals(String.class, setterFirst.getPropertyType()); + + PropertyDescriptor getterFirst = descriptorsOf(InheritedSetterBean.class).get("value"); + assertEquals(InheritedSetterBean.class.getMethod("getValue"), getterFirst.getReadMethod()); + assertEquals(ValueSetter.class.getMethod("setValue", String.class), getterFirst.getWriteMethod()); + assertEquals(String.class, getterFirst.getPropertyType()); + } + @Test public void getterAloneMakesAReadOnlyProperty() throws Exception { PropertyDescriptor pd = descriptorsOf(Bean.class).get("count"); diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/util/PropertySetterTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/util/PropertySetterTest.java index f29ed7ba8..d467aa218 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/util/PropertySetterTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/util/PropertySetterTest.java @@ -439,6 +439,22 @@ public void setPropertyWarnsWhenValueCannotBeConverted() { assertTrue(t.getCause() instanceof NumberFormatException); } + @Test + public void setPropertyWarnsWhenValueConvertsToNothing() { + // "maybe" is neither true nor false, so the conversion yields no value and + // the setter must not be invoked + house.setOpen(true); + setter.setProperty("open", "maybe"); + assertTrue(house.isOpen()); + Status s = statusWithMessage("Failed to set property [open] to value \"maybe\". "); + assertEquals(Status.WARN, s.getLevel()); + Throwable t = s.getThrowable(); + assertTrue(t instanceof PropertySetterException); + assertEquals("Conversion to type [boolean] failed.", t.getMessage()); + assertNull(t.getCause()); + assertEquals(1, statuses().size()); + } + @Test public void setPropertyWarnsWhenSetterThrows() { gizmoSetter().setProperty("exploding", "x"); From 2db148e1209919ee6be1d648fea085bb4b19d9f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:01:08 +0000 Subject: [PATCH 55/79] refactor: drop ContextInitializer's always-true search flags autoConfig() passed a local `verbose = true` to its private search helpers, whose `if (updateStatus)` checks therefore could never be false, and it guarded the first (system property) search with `if (!configured)` right after setting `configured = false`. Those branches could never be taken, so the 100% branch coverage gate could not be met while they existed. The helpers now always report the result of each resource search (as they always did), and the system property is searched unconditionally (as it always was). Behavior is unchanged: the same status messages are added, the system property still wins over assets/logback.xml, and a JoranException from configuring it still propagates without the assets being searched. ContextInitializerJvmTest runs on the plain JVM: it injects a class loader standing in for the APK and mocks JoranConfigurator's construction to pin which URL each search path (file, directory, URL, class-path resource, unknown name, assets, nothing found) hands to the configurator, and the statuses reported along the way. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../classic/util/ContextInitializer.java | 39 +-- .../util/ContextInitializerJvmTest.java | 265 ++++++++++++++++++ 2 files changed, 280 insertions(+), 24 deletions(-) create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/util/ContextInitializerJvmTest.java diff --git a/logback-android/src/main/java/ch/qos/logback/classic/util/ContextInitializer.java b/logback-android/src/main/java/ch/qos/logback/classic/util/ContextInitializer.java index 77c20a9ee..23a7a1f44 100644 --- a/logback-android/src/main/java/ch/qos/logback/classic/util/ContextInitializer.java +++ b/logback-android/src/main/java/ch/qos/logback/classic/util/ContextInitializer.java @@ -54,16 +54,14 @@ public ContextInitializer(LoggerContext loggerContext) { * Finds a configuration file by system property * @return the file; or {@code null} if not found */ - private URL findConfigFileFromSystemProperties(boolean updateStatus) { + private URL findConfigFileFromSystemProperties() { String logbackConfigFile = OptionHelper.getSystemProperty(CONFIG_FILE_PROPERTY); if (logbackConfigFile != null) { URL result = null; try { File file = new File(logbackConfigFile); if (file.exists() && file.isFile()) { - if (updateStatus) { - statusOnResourceSearch(logbackConfigFile, logbackConfigFile); - } + statusOnResourceSearch(logbackConfigFile, logbackConfigFile); result = file.toURI().toURL(); } else { result = new URL(logbackConfigFile); @@ -77,9 +75,7 @@ private URL findConfigFileFromSystemProperties(boolean updateStatus) { return result; } } finally { - if (updateStatus) { - statusOnResourceSearch(logbackConfigFile, result != null ? result.toString() : null); - } + statusOnResourceSearch(logbackConfigFile, result != null ? result.toString() : null); } } return null; @@ -89,23 +85,21 @@ private URL findConfigFileFromSystemProperties(boolean updateStatus) { * Finds a configuration file in the application's assets directory * @return the URL of the file; or {@code null} if not found */ - private URL findConfigFileURLFromAssets(boolean updateStatus) { - return getResource(AUTOCONFIG_FILE, this.classLoader, updateStatus); + private URL findConfigFileURLFromAssets() { + return getResource(AUTOCONFIG_FILE, this.classLoader); } /** * Uses the given classloader to search for a resource * @return the URL of the resource; or {@code null} if not found */ - private URL getResource(String filename, ClassLoader myClassLoader, boolean updateStatus) { + private URL getResource(String filename, ClassLoader myClassLoader) { URL url = myClassLoader.getResource(filename); - if (updateStatus) { - String resourcePath = null; - if (url != null) { - resourcePath = filename; - } - statusOnResourceSearch(filename, resourcePath); + String resourcePath = null; + if (url != null) { + resourcePath = filename; } + statusOnResourceSearch(filename, resourcePath); return url; } @@ -121,24 +115,21 @@ private URL getResource(String filename, ClassLoader myClassLoader, boolean upda public void autoConfig() throws JoranException { StatusListenerConfigHelper.installIfAsked(loggerContext); - boolean verbose = true; boolean configured = false; JoranConfigurator configurator = new JoranConfigurator(); configurator.setContext(loggerContext); // search system property - if (!configured) { - URL url = findConfigFileFromSystemProperties(verbose); - if (url != null) { - configurator.doConfigure(url); - configured = true; - } + URL url = findConfigFileFromSystemProperties(); + if (url != null) { + configurator.doConfigure(url); + configured = true; } // search assets if (!configured) { - URL assetsConfigXml = findConfigFileURLFromAssets(verbose); + URL assetsConfigXml = findConfigFileURLFromAssets(); if (assetsConfigXml != null) { configurator.doConfigure(assetsConfigXml); configured = true; diff --git a/logback-android/src/test/java/ch/qos/logback/classic/util/ContextInitializerJvmTest.java b/logback-android/src/test/java/ch/qos/logback/classic/util/ContextInitializerJvmTest.java new file mode 100644 index 000000000..c59351b07 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/util/ContextInitializerJvmTest.java @@ -0,0 +1,265 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URI; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Pattern; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.joran.JoranConfigurator; +import ch.qos.logback.core.joran.spi.JoranException; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.util.Loader; + +/** + * Plain-JVM tests (no Robolectric) of the configuration search done by + * {@link ContextInitializer#autoConfig()}: first the file, URL or resource + * named by the {@code logback.configurationFile} system property, then + * {@code assets/logback.xml} from the initializer's class loader. + * + *

The class loader is injected so that the presence of + * {@code assets/logback.xml} does not depend on the test classpath, and the + * {@link JoranConfigurator} is mocked (its XML parser is Android's) so the + * tests pin which URL the search hands to it. + */ +public class ContextInitializerJvmTest { + + private static final String ASSET_STATUS_REGEX = ".*\\[" + ContextInitializer.AUTOCONFIG_FILE + "\\].*"; + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private final LoggerContext loggerContext = new LoggerContext(); + private String savedConfigFileProperty; + private URLClassLoader apkClassLoader; + private File apk; + + @Before + public void setUp() throws IOException { + savedConfigFileProperty = System.getProperty(ContextInitializer.CONFIG_FILE_PROPERTY); + System.clearProperty(ContextInitializer.CONFIG_FILE_PROPERTY); + apk = tmp.newFolder("apk"); + } + + @After + public void tearDown() throws IOException { + if (savedConfigFileProperty == null) { + System.clearProperty(ContextInitializer.CONFIG_FILE_PROPERTY); + } else { + System.setProperty(ContextInitializer.CONFIG_FILE_PROPERTY, savedConfigFileProperty); + } + if (apkClassLoader != null) { + apkClassLoader.close(); + } + loggerContext.stop(); + } + + @Test + public void configuresFromAssetsWhenSystemPropertyIsNotSet() throws Exception { + File asset = writeFile(new File(apk, ContextInitializer.AUTOCONFIG_FILE)); + + List configured = autoConfig(); + + assertEquals(Collections.singletonList(asset.toURI().toURL()), configured); + assertEquals(Status.INFO, statusLevel("Found resource [" + ContextInitializer.AUTOCONFIG_FILE + + "] at [" + ContextInitializer.AUTOCONFIG_FILE + "]")); + } + + @Test + public void reportsMissingAssetAndConfiguresNothing() throws Exception { + List configured = autoConfig(); + + assertEquals(Collections.emptyList(), configured); + assertEquals(Status.INFO, statusLevel("Could NOT find resource [" + ContextInitializer.AUTOCONFIG_FILE + "]")); + } + + @Test + public void configFileFromSystemPropertyWinsOverAssets() throws Exception { + writeFile(new File(apk, ContextInitializer.AUTOCONFIG_FILE)); + File configFile = writeFile(new File(tmp.getRoot(), "fromProperty.xml")); + System.setProperty(ContextInitializer.CONFIG_FILE_PROPERTY, configFile.getAbsolutePath()); + + List configured = autoConfig(); + + assertEquals(Collections.singletonList(configFile.toURI().toURL()), configured); + assertEquals(Status.INFO, statusLevel("Found resource [" + configFile.getAbsolutePath() + + "] at [" + configFile.getAbsolutePath() + "]")); + assertEquals(Status.INFO, statusLevel("Found resource [" + configFile.getAbsolutePath() + + "] at [" + configFile.toURI().toURL() + "]")); + // the assets are not searched once the system property found a config + assertEquals(0, matchingStatuses(ASSET_STATUS_REGEX).size()); + } + + @Test + public void failureToConfigureFromSystemPropertyIsNotMaskedByAssets() throws Exception { + writeFile(new File(apk, ContextInitializer.AUTOCONFIG_FILE)); + File configFile = writeFile(new File(tmp.getRoot(), "broken.xml")); + System.setProperty(ContextInitializer.CONFIG_FILE_PROPERTY, configFile.getAbsolutePath()); + final JoranException failure = new JoranException("broken config"); + final ContextInitializer initializer = initializerLoadingFromApk(); + + try (MockedConstruction configurators = mockConstruction(JoranConfigurator.class, + (mock, context) -> doThrow(failure).when(mock).doConfigure(any(URL.class)))) { + JoranException thrown = assertThrows(JoranException.class, initializer::autoConfig); + assertSame(failure, thrown); + verify(configurators.constructed().get(0)).doConfigure(configFile.toURI().toURL()); + } + assertEquals(0, matchingStatuses(ASSET_STATUS_REGEX).size()); + } + + @Test + public void urlNamedBySystemPropertyIsConfigured() throws Exception { + writeFile(new File(apk, ContextInitializer.AUTOCONFIG_FILE)); + URL url = URI.create("http://localhost/logback-config.xml").toURL(); + System.setProperty(ContextInitializer.CONFIG_FILE_PROPERTY, url.toString()); + + List configured = autoConfig(); + + assertEquals(Collections.singletonList(url), configured); + assertEquals(Status.INFO, statusLevel("Found resource [" + url + "] at [" + url + "]")); + assertEquals(0, matchingStatuses(ASSET_STATUS_REGEX).size()); + } + + @Test + public void directoryNamedBySystemPropertyIsNotAConfigFileSoAssetsAreSearched() throws Exception { + File asset = writeFile(new File(apk, ContextInitializer.AUTOCONFIG_FILE)); + File directory = tmp.newFolder("configDir"); + System.setProperty(ContextInitializer.CONFIG_FILE_PROPERTY, directory.getAbsolutePath()); + + List configured = autoConfig(); + + assertEquals(Collections.singletonList(asset.toURI().toURL()), configured); + assertEquals(Status.INFO, statusLevel("Could NOT find resource [" + directory.getAbsolutePath() + "]")); + assertEquals(0, matchingStatuses("Found resource \\[" + Pattern.quote(directory.getAbsolutePath()) + + "\\].*").size()); + } + + @Test + public void unknownResourceNamedBySystemPropertyFallsBackToAssets() throws Exception { + File asset = writeFile(new File(apk, ContextInitializer.AUTOCONFIG_FILE)); + System.setProperty(ContextInitializer.CONFIG_FILE_PROPERTY, "no-such-logback-config.xml"); + + List configured = autoConfig(); + + assertEquals(Collections.singletonList(asset.toURI().toURL()), configured); + assertEquals(Status.INFO, statusLevel("Could NOT find resource [no-such-logback-config.xml]")); + } + + @Test + public void resourceNamedBySystemPropertyIsLoadedFromClassLoader() throws Exception { + writeFile(new File(apk, ContextInitializer.AUTOCONFIG_FILE)); + File resource = writeFile(new File(apk, "custom-logback.xml")); + System.setProperty(ContextInitializer.CONFIG_FILE_PROPERTY, "custom-logback.xml"); + + List configured = autoConfig(); + + assertEquals(Collections.singletonList(resource.toURI().toURL()), configured); + assertEquals(Status.INFO, statusLevel("Found resource [custom-logback.xml] at [" + + resource.toURI().toURL() + "]")); + assertEquals(0, matchingStatuses(ASSET_STATUS_REGEX).size()); + } + + /** + * Runs {@link ContextInitializer#autoConfig()} over the {@code apk} folder + * and returns the URLs it handed to the configurator, in order. + */ + private List autoConfig() throws Exception { + ContextInitializer initializer = initializerLoadingFromApk(); + try (MockedConstruction configurators = mockConstruction(JoranConfigurator.class)) { + initializer.autoConfig(); + + assertEquals(1, configurators.constructed().size()); + JoranConfigurator configurator = configurators.constructed().get(0); + verify(configurator).setContext(loggerContext); + ArgumentCaptor urls = ArgumentCaptor.forClass(URL.class); + verify(configurator, atLeast(0)).doConfigure(urls.capture()); + return urls.getAllValues(); + } + } + + /** + * Creates a {@link ContextInitializer} whose class loader sees only the + * contents of the {@code apk} folder, standing in for an app's APK. + */ + private ContextInitializer initializerLoadingFromApk() throws IOException { + apkClassLoader = new URLClassLoader(new URL[] { apk.toURI().toURL() }, null); + try (MockedStatic loader = mockStatic(Loader.class)) { + loader.when(() -> Loader.getClassLoaderOfObject(any())).thenReturn(apkClassLoader); + return new ContextInitializer(loggerContext); + } + } + + private static File writeFile(File file) throws IOException { + File parent = file.getParentFile(); + if (!parent.isDirectory() && !parent.mkdirs()) { + throw new IOException("cannot create " + parent); + } + try (OutputStream out = new FileOutputStream(file)) { + out.write("".getBytes("UTF-8")); + } + return file; + } + + /** Level of the one status whose message is exactly {@code message}. */ + private int statusLevel(String message) { + List found = new ArrayList(); + for (Status s : loggerContext.getStatusManager().getCopyOfStatusList()) { + if (s.getMessage().equals(message)) { + found.add(s); + } + } + assertEquals("statuses with message [" + message + "]: " + found, 1, found.size()); + return found.get(0).getLevel(); + } + + private List matchingStatuses(String regex) { + List found = new ArrayList(); + for (Status s : loggerContext.getStatusManager().getCopyOfStatusList()) { + if (s.getMessage().matches(regex)) { + found.add(s); + } + } + return found; + } +} From 622a54aa1da0032b084bed83f6352247623c77fb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:11:45 +0000 Subject: [PATCH 56/79] test: cover classic joran actions and classic.util helpers Bring the classic joran actions and ch.qos.logback.classic.util classes to 100% line and branch coverage with plain-JVM tests: - ConditionalIncludeAction: FileNotFound/UnknownHost/no exception are reported as info, other failures as warnings with their cause; an include is skipped only when the State on the stack holds a URL. - FindIncludeAction: end() ignores an empty stack or a foreign top object, reports "no paths found", and reports a JoranException from including the found path. - ConfigurationAction: the logback.debug system property overrides the debug attribute, "false"/"null" do not enable debug, scan="false" schedules nothing, scanning without a main URL warns, a missing, malformed or unconvertible (IllegalStateException, injected) scan period falls back to the default, and getSystemProperty() returns null when the installed Properties deny access. - ContextNameAction, LevelAction, LoggerAction, RootLoggerAction, LoggerContextListenerAction, ReceiverAction: error paths (missing name/class, uninstantiable class, level outside a logger, with the line/column in the message), INHERITED/NULL levels, and the warning when the pushed object is no longer on top of the stack; listeners and receivers are given the context, started and registered. - ContextSelectorStaticBinder: key check, JNDI refusal, a selector class named by logback.ContextSelector, and its failures. - LogbackMDCAdapter: null keys, in-place vs copy-on-write removal, getKeys/getCopyOfContextMap without a map, and the deque methods. - DefaultNestedComponentRules, LevelToSyslogSeverity, LoggerNameUtil: the registered defaults, OFF/ALL rejection, getFirstSeparatorIndexOf. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../ConditionalIncludeActionJvmTest.java | 160 ++++++++++ .../joran/action/ConfigurationActionTest.java | 298 ++++++++++++++++++ .../joran/action/ContextNameActionTest.java | 73 +++++ .../joran/action/FindIncludeActionTest.java | 153 +++++++++ .../classic/joran/action/LevelActionTest.java | 128 ++++++++ .../joran/action/LoggerActionTest.java | 187 +++++++++++ .../LoggerContextListenerActionTest.java | 239 ++++++++++++++ .../joran/action/ReceiverActionTest.java | 193 ++++++++++++ .../joran/action/RootLoggerActionTest.java | 118 +++++++ .../util/ContextSelectorStaticBinderTest.java | 167 ++++++++++ .../util/DefaultNestedComponentRulesTest.java | 72 +++++ .../util/LevelToSyslogSeverityTest.java | 16 + .../classic/util/LogbackMDCAdapterTest.java | 107 +++++++ .../classic/util/LoggerNameUtilTest.java | 20 ++ 14 files changed, 1931 insertions(+) create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/joran/action/ConditionalIncludeActionJvmTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/joran/action/ConfigurationActionTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/joran/action/ContextNameActionTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/joran/action/FindIncludeActionTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/joran/action/LevelActionTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/joran/action/LoggerActionTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/joran/action/LoggerContextListenerActionTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/joran/action/ReceiverActionTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/joran/action/RootLoggerActionTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/util/ContextSelectorStaticBinderTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/util/DefaultNestedComponentRulesTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/classic/joran/action/ConditionalIncludeActionJvmTest.java b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/ConditionalIncludeActionJvmTest.java new file mode 100644 index 000000000..7f86ecd61 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/ConditionalIncludeActionJvmTest.java @@ -0,0 +1,160 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.net.UnknownHostException; +import java.util.List; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.xml.sax.helpers.AttributesImpl; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.status.Status; + +/** + * Plain-JVM tests (no Robolectric) of {@link ConditionalIncludeAction}: how it + * reports include failures and when it skips an include. The end-to-end + * behavior within {@code } is in + * {@link ConditionalIncludeActionTest}. + */ +public class ConditionalIncludeActionJvmTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private final Context context = new ContextBase(); + private final InterpretationContext ic = new InterpretationContext(context, null); + private final ConditionalIncludeAction action = new ConditionalIncludeAction(); + + @Before + public void setUp() { + action.setContext(context); + } + + @Test + public void missingFileIsReportedAsInfo() { + action.handleError("missing", new FileNotFoundException("x.xml")); + assertOnlyStatus(Status.INFO, "missing", null); + } + + @Test + public void unknownHostIsReportedAsInfo() { + action.handleError("unknown host", new UnknownHostException("nowhere")); + assertOnlyStatus(Status.INFO, "unknown host", null); + } + + @Test + public void errorWithoutExceptionIsReportedAsInfo() { + action.handleError("no attribute", null); + assertOnlyStatus(Status.INFO, "no attribute", null); + } + + @Test + public void otherFailuresAreReportedAsWarningWithTheirCause() { + IOException cause = new IOException("disk on fire"); + action.handleError("cannot read", cause); + assertOnlyStatus(Status.WARN, "cannot read", cause); + } + + @Test + public void includeIsSkippedOnceAPathWasFound() throws Exception { + ConditionalIncludeAction.State found = new ConditionalIncludeAction.State(); + found.setUrl(URI.create("file:/found.xml").toURL()); + ic.pushObject(found); + + action.begin(ic, "include", fileAttribute(tmp.newFile("other.xml"))); + + assertEquals(1, ic.getObjectStack().size()); + assertSame(found, ic.peekObject()); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } + + @Test + public void includeIsSearchedWhenTheStateOnTheStackHasNoPath() throws Exception { + ConditionalIncludeAction.State notFound = new ConditionalIncludeAction.State(); + ic.pushObject(notFound); + File file = tmp.newFile("included.xml"); + + action.begin(ic, "include", fileAttribute(file)); + + assertEquals(2, ic.getObjectStack().size()); + ConditionalIncludeAction.State state = (ConditionalIncludeAction.State) ic.peekObject(); + assertEquals(file.toURI().toURL(), state.getUrl()); + assertNull(notFound.getUrl()); + } + + @Test + public void foundPathIsPushedOntoAnEmptyStack() throws Exception { + File file = tmp.newFile("included.xml"); + + action.begin(ic, "include", fileAttribute(file)); + + assertEquals(1, ic.getObjectStack().size()); + assertEquals(file.toURI().toURL(), ((ConditionalIncludeAction.State) ic.peekObject()).getUrl()); + } + + @Test + public void missingPathPushesNothing() throws Exception { + action.begin(ic, "include", fileAttribute(new File(tmp.getRoot(), "missing.xml"))); + + assertTrue(ic.isEmpty()); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(statuses.toString(), 1, statuses.size()); + assertEquals(Status.INFO, statuses.get(0).getLevel()); + assertTrue(statuses.get(0).getMessage().startsWith("File does not exist [")); + } + + @Test + public void includeIsSearchedWhenTheStackHoldsNoState() throws Exception { + ic.pushObject("not a state"); + File file = tmp.newFile("included.xml"); + + action.begin(ic, "include", fileAttribute(file)); + + assertEquals(2, ic.getObjectStack().size()); + assertEquals(file.toURI().toURL(), ((ConditionalIncludeAction.State) ic.peekObject()).getUrl()); + } + + private static AttributesImpl fileAttribute(File file) { + AttributesImpl attributes = new AttributesImpl(); + attributes.addAttribute("", "file", "file", "CDATA", file.getAbsolutePath()); + return attributes; + } + + private void assertOnlyStatus(int level, String message, Throwable throwable) { + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(statuses.toString(), 1, statuses.size()); + Status status = statuses.get(0); + assertEquals(level, status.getLevel()); + assertEquals(message, status.getMessage()); + assertSame(throwable, status.getThrowable()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/joran/action/ConfigurationActionTest.java b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/ConfigurationActionTest.java new file mode 100644 index 000000000..9576cbd0e --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/ConfigurationActionTest.java @@ -0,0 +1,298 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mockStatic; + +import java.net.URI; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.xml.sax.helpers.AttributesImpl; + +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.joran.ReconfigureOnChangeTask; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.joran.util.ConfigurationWatchListUtil; +import ch.qos.logback.core.status.OnConsoleStatusListener; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusListener; +import ch.qos.logback.core.util.Duration; + +public class ConfigurationActionTest { + + private final LoggerContext context = new LoggerContext(); + private final InterpretationContext ic = new InterpretationContext(context, null); + private final ConfigurationAction action = new ConfigurationAction(); + private final AttributesImpl attributes = new AttributesImpl(); + private String savedDebugProperty; + + @Before + public void setUp() throws Exception { + savedDebugProperty = System.getProperty(ConfigurationAction.DEBUG_SYSTEM_PROPERTY_KEY); + System.clearProperty(ConfigurationAction.DEBUG_SYSTEM_PROPERTY_KEY); + action.setContext(context); + } + + @After + public void tearDown() { + if (savedDebugProperty == null) { + System.clearProperty(ConfigurationAction.DEBUG_SYSTEM_PROPERTY_KEY); + } else { + System.setProperty(ConfigurationAction.DEBUG_SYSTEM_PROPERTY_KEY, savedDebugProperty); + } + context.stop(); + } + + @Test + public void debugSystemPropertyOverridesTheDebugAttribute() { + System.setProperty(ConfigurationAction.DEBUG_SYSTEM_PROPERTY_KEY, "true"); + addAttribute(ConfigurationAction.INTERNAL_DEBUG_ATTR, "false"); + + action.begin(ic, "configuration", attributes); + + assertEquals(1, onConsoleStatusListeners()); + assertEquals(0, statusCount("debug attribute not set")); + } + + @Test + public void debugAttributeSetToTheStringNullDoesNotEnableDebug() { + addAttribute(ConfigurationAction.INTERNAL_DEBUG_ATTR, "null"); + + action.begin(ic, "configuration", attributes); + + assertEquals(0, onConsoleStatusListeners()); + assertEquals(1, statusCount("debug attribute not set")); + } + + @Test + public void debugAttributeSetToFalseDoesNotEnableDebug() { + addAttribute(ConfigurationAction.INTERNAL_DEBUG_ATTR, "False"); + + action.begin(ic, "configuration", attributes); + + assertEquals(0, onConsoleStatusListeners()); + assertEquals(1, statusCount("debug attribute not set")); + } + + @Test + public void debugAttributeSetToTrueEnablesDebug() { + addAttribute(ConfigurationAction.INTERNAL_DEBUG_ATTR, "true"); + + action.begin(ic, "configuration", attributes); + + assertEquals(1, onConsoleStatusListeners()); + assertEquals(0, statusCount("debug attribute not set")); + } + + @Test + public void beginPushesTheContextAndEndPopsIt() { + addAttribute(ConfigurationAction.PACKAGING_DATA_ATTR, "true"); + + action.begin(ic, "configuration", attributes); + assertSame(context, ic.peekObject()); + assertTrue(context.isPackagingDataEnabled()); + + action.end(ic, "configuration"); + assertTrue(ic.isEmpty()); + assertEquals(1, statusCount("End of configuration.")); + } + + @Test + public void scanSetToFalseDoesNotScheduleScanning() throws Exception { + ConfigurationWatchListUtil.setMainWatchURL(context, URI.create("file:/logback.xml").toURL()); + addAttribute(ConfigurationAction.SCAN_ATTR, "FALSE"); + + action.processScanAttrib(ic, attributes); + + assertNull(context.getObject(CoreConstants.RECONFIGURE_ON_CHANGE_TASK)); + assertTrue(context.getScheduledFutures().isEmpty()); + assertEquals(0, statusCount("Will scan for changes in .*")); + } + + @Test + public void scanWithoutMainConfigurationFileIsNotPossible() { + addAttribute(ConfigurationAction.SCAN_ATTR, "true"); + + action.processScanAttrib(ic, attributes); + + assertNull(context.getObject(CoreConstants.RECONFIGURE_ON_CHANGE_TASK)); + assertTrue(context.getScheduledFutures().isEmpty()); + assertEquals(Status.WARN, onlyStatus("Due to missing top level configuration file, reconfiguration on change " + + "\\(configuration file scanning\\) cannot be done\\.").getLevel()); + } + + @Test + public void scanWithoutScanPeriodUsesTheDefault() throws Exception { + URL mainURL = URI.create("file:/logback.xml").toURL(); + ConfigurationWatchListUtil.setMainWatchURL(context, mainURL); + addAttribute(ConfigurationAction.SCAN_ATTR, "true"); + + action.processScanAttrib(ic, attributes); + + assertEquals(0, statusCount("Failed to parse .*")); + assertScanningScheduledWithDefaultPeriod(mainURL); + } + + @Test + public void scanPeriodSetsTheScanningPeriod() throws Exception { + URL mainURL = URI.create("file:/logback.xml").toURL(); + ConfigurationWatchListUtil.setMainWatchURL(context, mainURL); + addAttribute(ConfigurationAction.SCAN_ATTR, "true"); + addAttribute(ConfigurationAction.SCAN_PERIOD_ATTR, "30 seconds"); + + action.processScanAttrib(ic, attributes); + + assertTrue(context.getObject(CoreConstants.RECONFIGURE_ON_CHANGE_TASK) instanceof ReconfigureOnChangeTask); + assertEquals(1, context.getScheduledFutures().size()); + assertEquals(1, statusCount("Will scan for changes in \\[" + mainURL + "\\] ")); + assertEquals(1, statusCount("Setting ReconfigureOnChangeTask scanning period to 30 seconds")); + assertEquals(0, statusCount("Failed to parse .*")); + assertEquals(0, statusCount("No 'scanPeriod' specified.*")); + } + + @Test + public void malformedScanPeriodFallsBackToTheDefault() throws Exception { + URL mainURL = URI.create("file:/logback.xml").toURL(); + ConfigurationWatchListUtil.setMainWatchURL(context, mainURL); + addAttribute(ConfigurationAction.SCAN_ATTR, "true"); + addAttribute(ConfigurationAction.SCAN_PERIOD_ATTR, "every now and then"); + + action.processScanAttrib(ic, attributes); + + Status warning = onlyStatus("Failed to parse 'scanPeriod' attribute \\[every now and then\\]"); + assertEquals(Status.WARN, warning.getLevel()); + assertTrue(warning.getThrowable() instanceof IllegalArgumentException); + assertScanningScheduledWithDefaultPeriod(mainURL); + } + + @Test + public void scanPeriodWithUnexpectedUnitFallsBackToTheDefault() throws Exception { + URL mainURL = URI.create("file:/logback.xml").toURL(); + ConfigurationWatchListUtil.setMainWatchURL(context, mainURL); + addAttribute(ConfigurationAction.SCAN_ATTR, "true"); + addAttribute(ConfigurationAction.SCAN_PERIOD_ATTR, "2 fortnights"); + IllegalStateException unexpectedUnit = new IllegalStateException("Unexpected fortnights"); + assertNotNull(ConfigurationAction.SCAN_PERIOD_DEFAULT); // initialized before Duration is mocked + + // Duration.valueOf() signals a unit it matched but cannot convert with an + // IllegalStateException; its unit pattern currently only matches units it + // converts, so the failure is injected + try (MockedStatic duration = mockStatic(Duration.class)) { + duration.when(() -> Duration.valueOf("2 fortnights")).thenThrow(unexpectedUnit); + + action.processScanAttrib(ic, attributes); + } + + Status warning = onlyStatus("Failed to parse 'scanPeriod' attribute \\[2 fortnights\\]"); + assertEquals(Status.WARN, warning.getLevel()); + assertSame(unexpectedUnit, warning.getThrowable()); + assertScanningScheduledWithDefaultPeriod(mainURL); + } + + @Test + public void getSystemPropertyReadsTheSystemProperty() { + String key = "ConfigurationActionTest." + System.nanoTime(); + System.setProperty(key, "value"); + try { + assertEquals("value", action.getSystemProperty(key)); + } finally { + System.clearProperty(key); + } + assertNull(action.getSystemProperty(key)); + } + + @Test + public void getSystemPropertyReturnsNullWhenAccessIsDenied() { + final String key = "ConfigurationActionTest.denied"; + Properties original = System.getProperties(); + // System.getProperty() delegates to the installed Properties object, which + // can refuse access the way a SecurityManager would + Properties denying = new Properties(original) { + private static final long serialVersionUID = 1L; + + @Override + public String getProperty(String name) { + if (key.equals(name)) { + throw new SecurityException("access denied to " + name); + } + return super.getProperty(name); + } + }; + System.setProperties(denying); + try { + assertNull(action.getSystemProperty(key)); + } finally { + System.setProperties(original); + } + } + + private void assertScanningScheduledWithDefaultPeriod(URL mainURL) { + assertTrue(context.getObject(CoreConstants.RECONFIGURE_ON_CHANGE_TASK) instanceof ReconfigureOnChangeTask); + assertEquals(1, context.getScheduledFutures().size()); + assertEquals(Status.INFO, + onlyStatus("No 'scanPeriod' specified. Defaulting to " + ConfigurationAction.SCAN_PERIOD_DEFAULT).getLevel()); + assertEquals(1, statusCount("Will scan for changes in \\[" + mainURL + "\\] ")); + assertEquals(1, statusCount( + "Setting ReconfigureOnChangeTask scanning period to " + ConfigurationAction.SCAN_PERIOD_DEFAULT)); + } + + private void addAttribute(String name, String value) { + attributes.addAttribute("", name, name, "CDATA", value); + } + + private int onConsoleStatusListeners() { + int count = 0; + for (StatusListener listener : context.getStatusManager().getCopyOfStatusListenerList()) { + if (listener instanceof OnConsoleStatusListener) { + count++; + } + } + return count; + } + + private List statusesMatching(String regex) { + List found = new ArrayList(); + for (Status status : context.getStatusManager().getCopyOfStatusList()) { + if (status.getMessage().matches(regex)) { + found.add(status); + } + } + return found; + } + + private int statusCount(String regex) { + return statusesMatching(regex).size(); + } + + private Status onlyStatus(String regex) { + List found = statusesMatching(regex); + assertEquals(found.toString(), 1, found.size()); + return found.get(0); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/joran/action/ContextNameActionTest.java b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/ContextNameActionTest.java new file mode 100644 index 000000000..e41a07520 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/ContextNameActionTest.java @@ -0,0 +1,73 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.List; + +import org.junit.Before; +import org.junit.Test; +import org.xml.sax.helpers.AttributesImpl; + +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.status.Status; + +public class ContextNameActionTest { + + private final LoggerContext context = new LoggerContext(); + private final InterpretationContext ic = new InterpretationContext(context, null); + private final ContextNameAction action = new ContextNameAction(); + + @Before + public void setUp() { + action.setContext(context); + } + + @Test + public void bodyNamesTheContextAfterSubstitution() throws Exception { + context.putProperty("appName", "myApp"); + + action.begin(ic, "contextName", new AttributesImpl()); + action.body(ic, "${appName}"); + action.end(ic, "contextName"); + + assertEquals("myApp", context.getName()); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(statuses.toString(), 1, statuses.size()); + assertEquals(Status.INFO, statuses.get(0).getLevel()); + assertEquals("Setting logger context name as [myApp]", statuses.get(0).getMessage()); + assertTrue(ic.isEmpty()); + } + + @Test + public void renamingAnAlreadyNamedContextIsReportedAsError() { + context.setName("first"); + + action.body(ic, "second"); + + assertEquals("first", context.getName()); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(statuses.toString(), 2, statuses.size()); + assertEquals("Setting logger context name as [second]", statuses.get(0).getMessage()); + Status error = statuses.get(1); + assertEquals(Status.ERROR, error.getLevel()); + assertEquals("Failed to rename context [first] as [second]", error.getMessage()); + assertTrue(error.getThrowable() instanceof IllegalStateException); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/joran/action/FindIncludeActionTest.java b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/FindIncludeActionTest.java new file mode 100644 index 000000000..71da1a31b --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/FindIncludeActionTest.java @@ -0,0 +1,153 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.net.URI; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; +import org.xml.sax.helpers.AttributesImpl; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.joran.spi.JoranException; +import ch.qos.logback.core.status.Status; + +/** + * Tests {@link FindIncludeAction#end}, which includes the path found by the + * nested {@link ConditionalIncludeAction}s, if any. The include itself is + * stubbed: parsing it needs Android's XML parser (see + * {@link ConditionalIncludeActionTest} for the end-to-end behavior). + */ +public class FindIncludeActionTest { + + private final Context context = new ContextBase(); + private final InterpretationContext ic = new InterpretationContext(context, null); + private final List included = new ArrayList(); + private JoranException includeFailure; + + private final FindIncludeAction action = new FindIncludeAction() { + @Override + protected void processInclude(InterpretationContext ic, URL url) throws JoranException { + included.add(url); + if (includeFailure != null) { + throw includeFailure; + } + } + }; + + @Before + public void setUp() { + action.setContext(context); + } + + @Test + public void beginLeavesTheStackAlone() throws Exception { + action.begin(ic, "findInclude", new AttributesImpl()); + assertTrue(ic.isEmpty()); + assertTrue(statuses().isEmpty()); + } + + @Test + public void endWithEmptyStackIncludesNothing() throws Exception { + action.end(ic, "findInclude"); + + assertTrue(included.isEmpty()); + assertTrue(statuses().isEmpty()); + } + + @Test + public void endLeavesAForeignObjectOnTheStack() throws Exception { + ic.pushObject("not a state"); + + action.end(ic, "findInclude"); + + assertEquals("not a state", ic.peekObject()); + assertTrue(included.isEmpty()); + assertTrue(statuses().isEmpty()); + } + + @Test + public void endReportsWhenNoPathWasFound() throws Exception { + ic.pushObject(new ConditionalIncludeAction.State()); + + action.end(ic, "findInclude"); + + assertTrue(ic.isEmpty()); + assertTrue(included.isEmpty()); + assertOnlyStatuses(new int[] { Status.INFO }, "No paths found from includes"); + } + + @Test + public void endIncludesTheFoundPath() throws Exception { + URL url = URI.create("file:/found.xml").toURL(); + pushFoundPath(url); + + action.end(ic, "findInclude"); + + assertTrue(ic.isEmpty()); + assertEquals(1, included.size()); + assertSame(url, included.get(0)); + assertOnlyStatuses(new int[] { Status.INFO }, "Path found [file:/found.xml]"); + } + + @Test + public void endReportsFailureToIncludeTheFoundPath() throws Exception { + URL url = URI.create("file:/found.xml").toURL(); + pushFoundPath(url); + includeFailure = new JoranException("cannot include"); + + action.end(ic, "findInclude"); + + assertTrue(ic.isEmpty()); + assertEquals(1, included.size()); + assertOnlyStatuses(new int[] { Status.INFO, Status.ERROR }, + "Path found [file:/found.xml]", "Failed to process include [file:/found.xml]"); + assertSame(includeFailure, statuses().get(1).getThrowable()); + } + + @Test + public void recorderIsBoundToTheActionsContext() { + assertSame(context, action.createRecorder(null, null).getContext()); + } + + private void pushFoundPath(URL url) { + ConditionalIncludeAction.State state = new ConditionalIncludeAction.State(); + state.setUrl(url); + ic.pushObject(state); + } + + private List statuses() { + return context.getStatusManager().getCopyOfStatusList(); + } + + private void assertOnlyStatuses(int[] levels, String... messages) { + List statuses = statuses(); + assertEquals(statuses.toString(), messages.length, statuses.size()); + for (int i = 0; i < messages.length; i++) { + assertEquals(levels[i], statuses.get(i).getLevel()); + assertEquals(messages[i], statuses.get(i).getMessage()); + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/joran/action/LevelActionTest.java b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/LevelActionTest.java new file mode 100644 index 000000000..a07c2c50d --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/LevelActionTest.java @@ -0,0 +1,128 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.List; + +import org.junit.Before; +import org.junit.Test; +import org.xml.sax.helpers.AttributesImpl; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.core.joran.action.ActionConst; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.status.Status; + +@SuppressWarnings("deprecation") +public class LevelActionTest { + + private final LoggerContext context = new LoggerContext(); + private final InterpretationContext ic = new InterpretationContext(context, null); + private final LevelAction action = new LevelAction(); + private final Logger logger = context.getLogger("a.b"); + + @Before + public void setUp() { + action.setContext(context); + logger.setLevel(Level.WARN); + } + + @Test + public void levelOutsideOfALoggerIsAnError() { + ic.pushObject("not a logger"); + + action.begin(ic, "level", value("INFO")); + + assertTrue(action.inError); + assertEquals("not a logger", ic.peekObject()); + assertEquals(Level.WARN, logger.getLevel()); + assertOnlyStatus(Status.ERROR, "For element , could not find a logger at the top of execution stack."); + } + + @Test + public void valueSetsTheLevelOfTheEnclosingLogger() { + ic.pushObject(logger); + + action.begin(ic, "level", value("INFO")); + + assertFalse(action.inError); + assertEquals(Level.INFO, logger.getLevel()); + assertOnlyStatus(Status.INFO, "a.b level set to INFO"); + } + + @Test + public void unknownValueSetsDebug() { + ic.pushObject(logger); + + action.begin(ic, "level", value("LOUD")); + + assertEquals(Level.DEBUG, logger.getLevel()); + } + + @Test + public void inheritedValueMakesTheLevelInherited() { + ic.pushObject(logger); + + action.begin(ic, "level", value("inherited")); + + assertNull(logger.getLevel()); + assertOnlyStatus(Status.INFO, "a.b level set to null"); + } + + @Test + public void nullValueMakesTheLevelInherited() { + ic.pushObject(logger); + + action.begin(ic, "level", value("Null")); + + assertNull(logger.getLevel()); + assertOnlyStatus(Status.INFO, "a.b level set to null"); + } + + @Test + public void endAndFinishLeaveTheStackAndLoggerAlone() { + ic.pushObject(logger); + + action.end(ic, "level"); + action.finish(ic); + + assertSame(logger, ic.peekObject()); + assertEquals(1, ic.getObjectStack().size()); + assertEquals(Level.WARN, logger.getLevel()); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } + + private static AttributesImpl value(String value) { + AttributesImpl attributes = new AttributesImpl(); + attributes.addAttribute("", ActionConst.VALUE_ATTR, ActionConst.VALUE_ATTR, "CDATA", value); + return attributes; + } + + private void assertOnlyStatus(int level, String message) { + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(statuses.toString(), 1, statuses.size()); + assertEquals(level, statuses.get(0).getLevel()); + assertEquals(message, statuses.get(0).getMessage()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/joran/action/LoggerActionTest.java b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/LoggerActionTest.java new file mode 100644 index 000000000..0e0f338a9 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/LoggerActionTest.java @@ -0,0 +1,187 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.List; + +import org.junit.Before; +import org.junit.Test; +import org.xml.sax.helpers.AttributesImpl; +import org.xml.sax.helpers.LocatorImpl; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.core.joran.action.Action; +import ch.qos.logback.core.joran.action.ActionConst; +import ch.qos.logback.core.joran.spi.ElementPath; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.joran.spi.Interpreter; +import ch.qos.logback.core.joran.spi.SimpleRuleStore; +import ch.qos.logback.core.status.Status; + +public class LoggerActionTest { + + private final LoggerContext context = new LoggerContext(); + private final Interpreter interpreter = new Interpreter(context, new SimpleRuleStore(context), new ElementPath()); + private final InterpretationContext ic = interpreter.getInterpretationContext(); + private final LoggerAction action = new LoggerAction(); + private final AttributesImpl attributes = new AttributesImpl(); + + @Before + public void setUp() { + action.setContext(context); + LocatorImpl locator = new LocatorImpl(); + locator.setLineNumber(12); + locator.setColumnNumber(5); + interpreter.setDocumentLocator(locator); + } + + @Test + public void missingNameIsReportedWithTheLocation() { + Object parent = new Object(); + ic.pushObject(parent); + + action.begin(ic, "logger", attributes); + + assertTrue(action.inError); + assertNull(action.logger); + assertSame(parent, ic.peekObject()); + assertOnlyStatus(Status.ERROR, "No 'name' attribute in element logger, around line: 12, column: 5"); + } + + @Test + public void endAfterAnErrorLeavesTheStackAlone() { + Object parent = new Object(); + ic.pushObject(parent); + action.begin(ic, "logger", attributes); + + action.end(ic, "logger"); + + assertSame(parent, ic.peekObject()); + assertEquals(1, ic.getObjectStack().size()); + assertEquals(1, context.getStatusManager().getCopyOfStatusList().size()); + } + + @Test + public void beginClearsAPreviousError() { + action.begin(ic, "logger", attributes); + assertTrue(action.inError); + + addAttribute(Action.NAME_ATTRIBUTE, "a.b"); + action.begin(ic, "logger", attributes); + + assertFalse(action.inError); + assertSame(context.getLogger("a.b"), ic.peekObject()); + } + + @Test + public void levelAndAdditivityAreSet() { + addAttribute(Action.NAME_ATTRIBUTE, "a.b"); + addAttribute(LoggerAction.LEVEL_ATTRIBUTE, "info"); + addAttribute(ActionConst.ADDITIVITY_ATTRIBUTE, "false"); + + action.begin(ic, "logger", attributes); + + Logger logger = context.getLogger("a.b"); + assertEquals(Level.INFO, logger.getLevel()); + assertFalse(logger.isAdditive()); + } + + @Test + public void inheritedLevelMakesTheLevelInherited() { + Logger logger = context.getLogger("a.b"); + logger.setLevel(Level.WARN); + addAttribute(Action.NAME_ATTRIBUTE, "a.b"); + addAttribute(LoggerAction.LEVEL_ATTRIBUTE, "Inherited"); + + action.begin(ic, "logger", attributes); + + assertNull(logger.getLevel()); + assertOnlyStatus(Status.INFO, "Setting level of logger [a.b] to null, i.e. INHERITED"); + } + + @Test + public void nullLevelMakesTheLevelInherited() { + Logger logger = context.getLogger("a.b"); + logger.setLevel(Level.WARN); + addAttribute(Action.NAME_ATTRIBUTE, "a.b"); + addAttribute(LoggerAction.LEVEL_ATTRIBUTE, "null"); + + action.begin(ic, "logger", attributes); + + assertNull(logger.getLevel()); + assertOnlyStatus(Status.INFO, "Setting level of logger [a.b] to null, i.e. INHERITED"); + } + + @Test + public void endPopsTheLogger() { + addAttribute(Action.NAME_ATTRIBUTE, "a.b"); + action.begin(ic, "logger", attributes); + assertSame(context.getLogger("a.b"), ic.peekObject()); + + action.end(ic, "logger"); + + assertTrue(ic.isEmpty()); + } + + @Test + public void endWarnsWhenTheLoggerIsNotOnTopOfTheStack() { + addAttribute(Action.NAME_ATTRIBUTE, "a.b"); + action.begin(ic, "logger", attributes); + ic.pushObject("intruder"); + + action.end(ic, "logger"); + + assertEquals("intruder", ic.peekObject()); + assertEquals(2, ic.getObjectStack().size()); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(statuses.toString(), 2, statuses.size()); + assertEquals(Status.WARN, statuses.get(0).getLevel()); + assertEquals("The object on the top the of the stack is not Logger[a.b] pushed earlier", + statuses.get(0).getMessage()); + assertEquals(Status.WARN, statuses.get(1).getLevel()); + assertEquals("It is: intruder", statuses.get(1).getMessage()); + } + + @Test + public void finishLeavesTheStackAlone() { + ic.pushObject("parent"); + + action.finish(ic); + + assertEquals("parent", ic.peekObject()); + assertEquals(1, ic.getObjectStack().size()); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } + + private void addAttribute(String name, String value) { + attributes.addAttribute("", name, name, "CDATA", value); + } + + private void assertOnlyStatus(int level, String message) { + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(statuses.toString(), 1, statuses.size()); + assertEquals(level, statuses.get(0).getLevel()); + assertEquals(message, statuses.get(0).getMessage()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/joran/action/LoggerContextListenerActionTest.java b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/LoggerContextListenerActionTest.java new file mode 100644 index 000000000..e5ec61aae --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/LoggerContextListenerActionTest.java @@ -0,0 +1,239 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; +import org.xml.sax.helpers.AttributesImpl; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.LoggerContextListener; +import ch.qos.logback.core.joran.action.Action; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.spi.ContextAwareBase; +import ch.qos.logback.core.spi.LifeCycle; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.util.IncompatibleClassException; + +public class LoggerContextListenerActionTest { + + private final LoggerContext context = new LoggerContext(); + private final InterpretationContext ic = new InterpretationContext(context, null); + private final LoggerContextListenerAction action = new LoggerContextListenerAction(); + + @Before + public void setUp() { + action.setContext(context); + } + + @Test + public void missingClassAttributeIsAnError() throws Exception { + action.begin(ic, "loggerContextListener", new AttributesImpl()); + + assertTrue(action.inError); + assertTrue(ic.isEmpty()); + List statuses = statuses(); + assertEquals(statuses.toString(), 1, statuses.size()); + assertEquals(Status.ERROR, statuses.get(0).getLevel()); + assertEquals("Mandatory \"class\" attribute not set for element", + statuses.get(0).getMessage()); + } + + @Test + public void endAfterAnErrorRegistersNothing() throws Exception { + Object parent = new Object(); + ic.pushObject(parent); + action.begin(ic, "loggerContextListener", new AttributesImpl()); + + action.end(ic, "loggerContextListener"); + + assertSame(parent, ic.peekObject()); + assertTrue(context.getCopyOfListenerList().isEmpty()); + } + + @Test + public void classThatIsNotAListenerIsAnError() throws Exception { + action.begin(ic, "loggerContextListener", classAttribute(String.class.getName())); + + assertTrue(action.inError); + assertTrue(ic.isEmpty()); + List statuses = statuses(); + assertEquals(statuses.toString(), 1, statuses.size()); + assertEquals(Status.ERROR, statuses.get(0).getLevel()); + assertEquals("Could not create LoggerContextListener of type java.lang.String].", statuses.get(0).getMessage()); + assertTrue(statuses.get(0).getThrowable() instanceof IncompatibleClassException); + + action.end(ic, "loggerContextListener"); + assertTrue(context.getCopyOfListenerList().isEmpty()); + } + + @Test + public void contextAwareLifeCycleListenerIsGivenTheContextStartedAndRegistered() throws Exception { + action.begin(ic, "loggerContextListener", classAttribute(StartableContextListener.class.getName())); + + assertFalse(action.inError); + StartableContextListener listener = (StartableContextListener) ic.peekObject(); + assertSame(context, listener.getContext()); + assertFalse(listener.isStarted()); + assertEquals("Adding LoggerContextListener of type [" + StartableContextListener.class.getName() + + "] to the object stack", statuses().get(0).getMessage()); + + action.end(ic, "loggerContextListener"); + + assertTrue(listener.isStarted()); + assertEquals(Collections.singletonList(listener), context.getCopyOfListenerList()); + assertTrue(ic.isEmpty()); + assertEquals(1, statusCount("Starting LoggerContextListener")); + } + + @Test + public void plainListenerIsRegisteredWithoutBeingStarted() throws Exception { + action.begin(ic, "loggerContextListener", classAttribute(PlainContextListener.class.getName())); + PlainContextListener listener = (PlainContextListener) ic.peekObject(); + + action.end(ic, "loggerContextListener"); + + assertEquals(Collections.singletonList(listener), context.getCopyOfListenerList()); + assertTrue(ic.isEmpty()); + assertEquals(0, statusCount("Starting LoggerContextListener")); + } + + @Test + public void endWarnsAndRegistersNothingWhenTheListenerIsNotOnTopOfTheStack() throws Exception { + action.begin(ic, "loggerContextListener", classAttribute(StartableContextListener.class.getName())); + StartableContextListener listener = (StartableContextListener) ic.peekObject(); + ic.pushObject("intruder"); + + action.end(ic, "loggerContextListener"); + + assertEquals("intruder", ic.peekObject()); + assertEquals(2, ic.getObjectStack().size()); + assertFalse(listener.isStarted()); + assertTrue(context.getCopyOfListenerList().isEmpty()); + Status warning = statuses().get(statuses().size() - 1); + assertEquals(Status.WARN, warning.getLevel()); + assertEquals("The object on the top the of the stack is not the LoggerContextListener pushed earlier.", + warning.getMessage()); + } + + @Test + public void beginClearsAPreviousError() throws Exception { + action.begin(ic, "loggerContextListener", new AttributesImpl()); + assertTrue(action.inError); + + action.begin(ic, "loggerContextListener", classAttribute(PlainContextListener.class.getName())); + + assertFalse(action.inError); + assertNotNull(ic.peekObject()); + } + + private static AttributesImpl classAttribute(String className) { + AttributesImpl attributes = new AttributesImpl(); + attributes.addAttribute("", Action.CLASS_ATTRIBUTE, Action.CLASS_ATTRIBUTE, "CDATA", className); + return attributes; + } + + private List statuses() { + return context.getStatusManager().getCopyOfStatusList(); + } + + private int statusCount(String message) { + int count = 0; + for (Status status : statuses()) { + if (status.getMessage().equals(message)) { + count++; + } + } + return count; + } + + /** A listener that is neither {@code ContextAware} nor a {@link LifeCycle}. */ + public static class PlainContextListener implements LoggerContextListener { + @Override + public boolean isResetResistant() { + return false; + } + + @Override + public void onStart(LoggerContext context) { + } + + @Override + public void onReset(LoggerContext context) { + } + + @Override + public void onStop(LoggerContext context) { + } + + @Override + public void onLevelChange(Logger logger, Level level) { + } + } + + /** A context-aware listener with a life cycle. */ + public static class StartableContextListener extends ContextAwareBase + implements LoggerContextListener, LifeCycle { + private boolean started; + + @Override + public void start() { + started = true; + } + + @Override + public void stop() { + started = false; + } + + @Override + public boolean isStarted() { + return started; + } + + @Override + public boolean isResetResistant() { + return false; + } + + @Override + public void onStart(LoggerContext context) { + } + + @Override + public void onReset(LoggerContext context) { + } + + @Override + public void onStop(LoggerContext context) { + } + + @Override + public void onLevelChange(Logger logger, Level level) { + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/joran/action/ReceiverActionTest.java b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/ReceiverActionTest.java new file mode 100644 index 000000000..cbf532e4b --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/ReceiverActionTest.java @@ -0,0 +1,193 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.List; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.xml.sax.helpers.AttributesImpl; +import org.xml.sax.helpers.LocatorImpl; + +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.net.ReceiverBase; +import ch.qos.logback.core.joran.action.Action; +import ch.qos.logback.core.joran.spi.ActionException; +import ch.qos.logback.core.joran.spi.ElementPath; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.joran.spi.Interpreter; +import ch.qos.logback.core.joran.spi.SimpleRuleStore; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.util.IncompatibleClassException; + +public class ReceiverActionTest { + + private final LoggerContext context = new LoggerContext(); + private final Interpreter interpreter = new Interpreter(context, new SimpleRuleStore(context), new ElementPath()); + private final InterpretationContext ic = interpreter.getInterpretationContext(); + private final ReceiverAction action = new ReceiverAction(); + + @Before + public void setUp() { + action.setContext(context); + LocatorImpl locator = new LocatorImpl(); + locator.setLineNumber(7); + interpreter.setDocumentLocator(locator); + } + + @After + public void tearDown() { + context.stop(); + } + + @Test + public void missingClassNameIsReportedWithTheLine() throws Exception { + action.begin(ic, "receiver", new AttributesImpl()); + + assertTrue(ic.isEmpty()); + List statuses = statuses(); + assertEquals(statuses.toString(), 1, statuses.size()); + assertEquals(Status.ERROR, statuses.get(0).getLevel()); + assertEquals("Missing class name for receiver. Near [receiver] line 7", statuses.get(0).getMessage()); + } + + @Test + public void endAfterMissingClassNameDoesNothing() throws Exception { + Object parent = new Object(); + ic.pushObject(parent); + action.begin(ic, "receiver", new AttributesImpl()); + + action.end(ic, "receiver"); + + assertSame(parent, ic.peekObject()); + assertEquals(1, statuses().size()); + } + + @Test + public void receiverIsInstantiatedGivenTheContextAndPushed() throws Exception { + action.begin(ic, "receiver", classAttribute(ReceiverActionTestReceiver.class.getName())); + + ReceiverActionTestReceiver receiver = (ReceiverActionTestReceiver) ic.peekObject(); + assertSame(context, receiver.getContext()); + assertFalse(receiver.isStarted()); + List statuses = statuses(); + assertEquals(statuses.toString(), 1, statuses.size()); + assertEquals(Status.INFO, statuses.get(0).getLevel()); + assertEquals("About to instantiate receiver of type [" + ReceiverActionTestReceiver.class.getName() + "]", + statuses.get(0).getMessage()); + } + + @Test + public void endStartsTheReceiverRegistersItWithTheContextAndPopsIt() throws Exception { + action.begin(ic, "receiver", classAttribute(ReceiverActionTestReceiver.class.getName())); + ReceiverActionTestReceiver receiver = (ReceiverActionTestReceiver) ic.peekObject(); + + action.end(ic, "receiver"); + + assertTrue(receiver.isStarted()); + assertTrue(ic.isEmpty()); + // registered components are stopped when the context is reset + context.reset(); + assertFalse(receiver.isStarted()); + assertEquals(1, receiver.stopCount); + } + + @Test + public void uninstantiableClassIsAnErrorAndAborts() { + ActionException e = assertThrows(ActionException.class, + () -> action.begin(ic, "receiver", classAttribute(String.class.getName()))); + + assertTrue(e.getCause() instanceof IncompatibleClassException); + assertTrue(ic.isEmpty()); + List statuses = statuses(); + Status error = statuses.get(statuses.size() - 1); + assertEquals(Status.ERROR, error.getLevel()); + assertEquals("Could not create a receiver of type [java.lang.String].", error.getMessage()); + assertSame(e.getCause(), error.getThrowable()); + } + + @Test + public void endAfterFailedInstantiationDoesNothing() throws Exception { + Object parent = new Object(); + ic.pushObject(parent); + assertThrows(ActionException.class, + () -> action.begin(ic, "receiver", classAttribute(String.class.getName()))); + int statusCount = statuses().size(); + + action.end(ic, "receiver"); + + assertSame(parent, ic.peekObject()); + assertEquals(statusCount, statuses().size()); + } + + @Test + public void endWarnsWhenTheReceiverIsNotOnTopOfTheStack() throws Exception { + action.begin(ic, "receiver", classAttribute(ReceiverActionTestReceiver.class.getName())); + ReceiverActionTestReceiver receiver = (ReceiverActionTestReceiver) ic.peekObject(); + ic.pushObject("intruder"); + + action.end(ic, "receiver"); + + // the receiver is still started; only the stack is left as it was + assertTrue(receiver.isStarted()); + assertEquals("intruder", ic.peekObject()); + assertEquals(2, ic.getObjectStack().size()); + Status warning = statuses().get(statuses().size() - 1); + assertEquals(Status.WARN, warning.getLevel()); + assertEquals("The object at the of the stack is not the remote pushed earlier.", warning.getMessage()); + } + + private static AttributesImpl classAttribute(String className) { + AttributesImpl attributes = new AttributesImpl(); + attributes.addAttribute("", Action.CLASS_ATTRIBUTE, Action.CLASS_ATTRIBUTE, "CDATA", className); + return attributes; + } + + private List statuses() { + return context.getStatusManager().getCopyOfStatusList(); + } + + /** A receiver that runs a no-op task and counts its stops. */ + public static class ReceiverActionTestReceiver extends ReceiverBase { + int stopCount; + + @Override + protected boolean shouldStart() { + return true; + } + + @Override + protected void onStop() { + stopCount++; + } + + @Override + protected Runnable getRunnableTask() { + return new Runnable() { + @Override + public void run() { + } + }; + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/joran/action/RootLoggerActionTest.java b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/RootLoggerActionTest.java new file mode 100644 index 000000000..9de451474 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/RootLoggerActionTest.java @@ -0,0 +1,118 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.List; + +import org.junit.Before; +import org.junit.Test; +import org.xml.sax.helpers.AttributesImpl; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.core.joran.action.ActionConst; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.status.Status; + +public class RootLoggerActionTest { + + private final LoggerContext context = new LoggerContext(); + private final InterpretationContext ic = new InterpretationContext(context, null); + private final RootLoggerAction action = new RootLoggerAction(); + private final Logger root = context.getLogger(Logger.ROOT_LOGGER_NAME); + + @Before + public void setUp() { + action.setContext(context); + } + + @Test + public void beginSetsTheRootLevelAndPushesTheRootLogger() { + AttributesImpl attributes = new AttributesImpl(); + attributes.addAttribute("", ActionConst.LEVEL_ATTRIBUTE, ActionConst.LEVEL_ATTRIBUTE, "CDATA", "error"); + + action.begin(ic, "root", attributes); + + assertFalse(action.inError); + assertSame(root, ic.peekObject()); + assertEquals(Level.ERROR, root.getLevel()); + } + + @Test + public void endPopsTheRootLogger() { + action.begin(ic, "root", new AttributesImpl()); + + action.end(ic, "root"); + + assertTrue(ic.isEmpty()); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } + + @Test + public void endAfterAnErrorLeavesTheStackAlone() { + action.begin(ic, "root", new AttributesImpl()); + action.inError = true; + + action.end(ic, "root"); + + assertSame(root, ic.peekObject()); + assertEquals(1, ic.getObjectStack().size()); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } + + @Test + public void beginClearsAPreviousError() { + action.inError = true; + + action.begin(ic, "root", new AttributesImpl()); + + assertFalse(action.inError); + } + + @Test + public void endWarnsWhenTheRootLoggerIsNotOnTopOfTheStack() { + action.begin(ic, "root", new AttributesImpl()); + ic.pushObject("intruder"); + + action.end(ic, "root"); + + assertEquals("intruder", ic.peekObject()); + assertEquals(2, ic.getObjectStack().size()); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(statuses.toString(), 2, statuses.size()); + assertEquals(Status.WARN, statuses.get(0).getLevel()); + assertEquals("The object on the top the of the stack is not the root logger", statuses.get(0).getMessage()); + assertEquals(Status.WARN, statuses.get(1).getLevel()); + assertEquals("It is: intruder", statuses.get(1).getMessage()); + } + + @Test + public void finishLeavesTheStackAlone() { + ic.pushObject(root); + + action.finish(ic); + + assertSame(root, ic.peekObject()); + assertEquals(1, ic.getObjectStack().size()); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/util/ContextSelectorStaticBinderTest.java b/logback-android/src/test/java/ch/qos/logback/classic/util/ContextSelectorStaticBinderTest.java new file mode 100644 index 000000000..0772e01c5 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/util/ContextSelectorStaticBinderTest.java @@ -0,0 +1,167 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; +import java.util.List; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.classic.ClassicConstants; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.selector.ContextSelector; +import ch.qos.logback.classic.selector.DefaultContextSelector; + +public class ContextSelectorStaticBinderTest { + + private final ContextSelectorStaticBinder binder = new ContextSelectorStaticBinder(); + private final LoggerContext defaultContext = new LoggerContext(); + private final Object key = new Object(); + private String savedSelectorProperty; + + @Before + public void setUp() { + savedSelectorProperty = System.getProperty(ClassicConstants.LOGBACK_CONTEXT_SELECTOR); + System.clearProperty(ClassicConstants.LOGBACK_CONTEXT_SELECTOR); + } + + @After + public void tearDown() { + if (savedSelectorProperty == null) { + System.clearProperty(ClassicConstants.LOGBACK_CONTEXT_SELECTOR); + } else { + System.setProperty(ClassicConstants.LOGBACK_CONTEXT_SELECTOR, savedSelectorProperty); + } + } + + @Test + public void singletonIsShared() { + assertSame(ContextSelectorStaticBinder.getSingleton(), ContextSelectorStaticBinder.getSingleton()); + } + + @Test + public void selectorIsNullBeforeInit() { + assertNull(binder.getContextSelector()); + } + + @Test + public void defaultSelectorServesTheDefaultContextWhenNoSelectorIsConfigured() throws Exception { + binder.init(defaultContext, key); + + ContextSelector selector = binder.getContextSelector(); + assertTrue(selector instanceof DefaultContextSelector); + assertSame(defaultContext, selector.getLoggerContext()); + } + + @Test + public void initAgainWithTheSameKeyIsAllowed() throws Exception { + binder.init(defaultContext, key); + LoggerContext otherContext = new LoggerContext(); + + binder.init(otherContext, key); + + assertSame(otherContext, binder.getContextSelector().getLoggerContext()); + } + + @Test + public void initWithAnotherKeyIsRejected() throws Exception { + binder.init(defaultContext, key); + ContextSelector selector = binder.getContextSelector(); + + IllegalAccessException e = assertThrows(IllegalAccessException.class, + () -> binder.init(new LoggerContext(), new Object())); + + assertEquals("Only certain classes can access this method.", e.getMessage()); + assertSame(selector, binder.getContextSelector()); + } + + @Test + public void jndiSelectorIsNotSupported() { + System.setProperty(ClassicConstants.LOGBACK_CONTEXT_SELECTOR, "JNDI"); + + RuntimeException e = assertThrows(RuntimeException.class, () -> binder.init(defaultContext, key)); + + assertEquals("JNDI not supported", e.getMessage()); + assertNull(binder.getContextSelector()); + } + + @Test + public void configuredSelectorClassIsInstantiatedWithTheDefaultContext() throws Exception { + System.setProperty(ClassicConstants.LOGBACK_CONTEXT_SELECTOR, StubContextSelector.class.getName()); + + binder.init(defaultContext, key); + + ContextSelector selector = binder.getContextSelector(); + assertEquals(StubContextSelector.class, selector.getClass()); + assertSame(defaultContext, selector.getLoggerContext()); + } + + @Test + public void unknownSelectorClassFails() { + System.setProperty(ClassicConstants.LOGBACK_CONTEXT_SELECTOR, "com.example.NoSuchContextSelector"); + + assertThrows(ClassNotFoundException.class, () -> binder.init(defaultContext, key)); + assertNull(binder.getContextSelector()); + } + + @Test + public void selectorClassWithoutLoggerContextConstructorFails() { + assertThrows(NoSuchMethodException.class, + () -> ContextSelectorStaticBinder.dynamicalContextSelector(defaultContext, Object.class.getName())); + } + + /** A context selector with the constructor the binder requires. */ + public static class StubContextSelector implements ContextSelector { + private final LoggerContext defaultContext; + + public StubContextSelector(LoggerContext defaultContext) { + this.defaultContext = defaultContext; + } + + @Override + public LoggerContext getLoggerContext() { + return defaultContext; + } + + @Override + public LoggerContext getLoggerContext(String name) { + return defaultContext; + } + + @Override + public LoggerContext getDefaultLoggerContext() { + return defaultContext; + } + + @Override + public LoggerContext detachLoggerContext(String loggerContextName) { + return null; + } + + @Override + public List getContextNames() { + return Collections.singletonList(defaultContext.getName()); + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/util/DefaultNestedComponentRulesTest.java b/logback-android/src/test/java/ch/qos/logback/classic/util/DefaultNestedComponentRulesTest.java new file mode 100644 index 000000000..2380f8d6f --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/util/DefaultNestedComponentRulesTest.java @@ -0,0 +1,72 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.classic.PatternLayout; +import ch.qos.logback.classic.encoder.PatternLayoutEncoder; +import ch.qos.logback.core.AppenderBase; +import ch.qos.logback.core.FileAppender; +import ch.qos.logback.core.UnsynchronizedAppenderBase; +import ch.qos.logback.core.joran.spi.DefaultNestedComponentRegistry; +import ch.qos.logback.core.net.ssl.SSLComponent; +import ch.qos.logback.core.net.ssl.SSLConfiguration; + +public class DefaultNestedComponentRulesTest { + + private final DefaultNestedComponentRegistry registry = new DefaultNestedComponentRegistry(); + + @Before + public void setUp() { + DefaultNestedComponentRules.addDefaultNestedComponentRegistryRules(registry); + } + + @Test + public void appendersDefaultTheirLayoutToPatternLayout() { + assertEquals(PatternLayout.class, registry.findDefaultComponentType(AppenderBase.class, "layout")); + assertEquals(PatternLayout.class, registry.findDefaultComponentType(UnsynchronizedAppenderBase.class, "layout")); + } + + @Test + public void appendersDefaultTheirEncoderToPatternLayoutEncoder() { + assertEquals(PatternLayoutEncoder.class, registry.findDefaultComponentType(AppenderBase.class, "encoder")); + assertEquals(PatternLayoutEncoder.class, + registry.findDefaultComponentType(UnsynchronizedAppenderBase.class, "encoder")); + // subclasses inherit the default through the registry's superclass walk + assertEquals(PatternLayoutEncoder.class, registry.findDefaultComponentType(FileAppender.class, "encoder")); + } + + @Test + public void sslRulesAreIncluded() { + assertEquals(SSLConfiguration.class, registry.findDefaultComponentType(SSLComponent.class, "ssl")); + } + + @Test + public void unrelatedPropertiesHaveNoDefault() { + assertNull(registry.findDefaultComponentType(AppenderBase.class, "filter")); + } + + @Test + public void utilityClassKeepsItsPublicConstructor() { + assertNotNull(new DefaultNestedComponentRules()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/util/LevelToSyslogSeverityTest.java b/logback-android/src/test/java/ch/qos/logback/classic/util/LevelToSyslogSeverityTest.java index 2459bdf97..66c79b52f 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/util/LevelToSyslogSeverityTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/util/LevelToSyslogSeverityTest.java @@ -16,6 +16,8 @@ package ch.qos.logback.classic.util; import static junit.framework.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; import org.junit.Test; @@ -46,6 +48,20 @@ public void smoke() { } + @Test + public void levelsWithoutPrintingMethodAreRejected() { + for (final Level level : new Level[] { Level.OFF, Level.ALL }) { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> LevelToSyslogSeverity.convert(createEventOfLevel(level))); + assertEquals("Level " + level + " is not a valid level for a printing method", e.getMessage()); + } + } + + @Test + public void utilityClassKeepsItsPublicConstructor() { + assertNotNull(new LevelToSyslogSeverity()); + } + ILoggingEvent createEventOfLevel(Level level) { LoggingEvent event = new LoggingEvent(); event.setLevel(level); diff --git a/logback-android/src/test/java/ch/qos/logback/classic/util/LogbackMDCAdapterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/util/LogbackMDCAdapterTest.java index ef2b42caf..87c1de4f8 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/util/LogbackMDCAdapterTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/util/LogbackMDCAdapterTest.java @@ -19,9 +19,15 @@ import static junit.framework.Assert.assertNotNull; import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertTrue; +import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Deque; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.concurrent.CountDownLatch; @@ -98,6 +104,107 @@ public void sequenceWithCopyContextMap() { assertSame(map0, mdcAdapter.copyOnThreadLocal.get()); } + @Test + public void putWithNullKeyIsRejected() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> mdcAdapter.put(null, "v")); + assertEquals("key cannot be null", e.getMessage()); + assertNull(mdcAdapter.copyOnThreadLocal.get()); + } + + @Test + public void removeAfterWriteEditsTheMapInPlace() { + mdcAdapter.put("k0", "v0"); + mdcAdapter.put("k1", "v1"); + Map map0 = mdcAdapter.copyOnThreadLocal.get(); + + mdcAdapter.remove("k0"); // last operation was a write: no copy needed + + assertSame(map0, mdcAdapter.copyOnThreadLocal.get()); + assertNull(map0.get("k0")); + assertEquals("v1", map0.get("k1")); + } + + @Test + public void removeAfterGetPropertyMapLeavesTheReturnedMapIntact() { + mdcAdapter.put("k0", "v0"); + Map map0 = mdcAdapter.getPropertyMap(); + + mdcAdapter.remove("k0"); // map was handed out: it must be copied first + + assertNotSame(map0, mdcAdapter.copyOnThreadLocal.get()); + assertEquals("v0", map0.get("k0")); + assertNull(mdcAdapter.get("k0")); + } + + @Test + public void getWithNullKeyReturnsNullEvenWithAMap() { + mdcAdapter.put("k0", "v0"); + assertNull(mdcAdapter.get(null)); + } + + @Test + public void getKeysIsNullWithoutAMap() { + assertNull(mdcAdapter.getKeys()); + } + + @Test + public void getKeysReturnsTheKeysOfTheMap() { + mdcAdapter.put("k0", "v0"); + mdcAdapter.put("k1", "v1"); + assertEquals(new HashSet(Arrays.asList("k0", "k1")), mdcAdapter.getKeys()); + } + + @Test + public void getCopyOfContextMapIsNullWithoutAMap() { + assertNull(mdcAdapter.getCopyOfContextMap()); + } + + @Test + public void getCopyOfContextMapIsADetachedCopy() { + mdcAdapter.put("k0", "v0"); + Map copy = mdcAdapter.getCopyOfContextMap(); + + assertNotSame(mdcAdapter.copyOnThreadLocal.get(), copy); + assertEquals(mdcAdapter.copyOnThreadLocal.get(), copy); + copy.put("k1", "v1"); + assertNull(mdcAdapter.get("k1")); + } + + @Test + public void pushByKeyPutsTheValue() { + mdcAdapter.pushByKey("k0", "v0"); + assertEquals("v0", mdcAdapter.get("k0")); + } + + @Test + public void popByKeyReturnsTheCurrentValue() { + assertNull(mdcAdapter.popByKey("k0")); + mdcAdapter.put("k0", "v0"); + assertEquals("v0", mdcAdapter.popByKey("k0")); + } + + @Test + public void getCopyOfDequeByKeyIsNullForAnAbsentKey() { + assertNull(mdcAdapter.getCopyOfDequeByKey("k0")); + } + + @Test + public void getCopyOfDequeByKeyHoldsTheCurrentValue() { + mdcAdapter.put("k0", "v0"); + Deque deque = mdcAdapter.getCopyOfDequeByKey("k0"); + assertEquals(Arrays.asList("v0"), Arrays.asList(deque.toArray(new String[0]))); + assertTrue(deque instanceof ArrayDeque); + } + + @Test + public void clearDequeByKeyNullsTheValue() { + mdcAdapter.put("k0", "v0"); + mdcAdapter.clearDequeByKey("k0"); + assertNull(mdcAdapter.get("k0")); + assertTrue(mdcAdapter.copyOnThreadLocal.get().containsKey("k0")); + } + // ================================================= /** diff --git a/logback-android/src/test/java/ch/qos/logback/classic/util/LoggerNameUtilTest.java b/logback-android/src/test/java/ch/qos/logback/classic/util/LoggerNameUtilTest.java index a65f5f882..513234f62 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/util/LoggerNameUtilTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/util/LoggerNameUtilTest.java @@ -21,6 +21,7 @@ import java.util.List; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; public class LoggerNameUtilTest { @@ -101,6 +102,25 @@ public void supportNestedClassesAtBeginning() { assertEquals(witnessList, partList); } + @Test + public void firstSeparatorIsTheEarliestDotOrDollar() { + assertEquals(3, LoggerNameUtil.getFirstSeparatorIndexOf("com.foo$Bar")); + assertEquals(3, LoggerNameUtil.getFirstSeparatorIndexOf("Foo$Bar.baz")); + assertEquals(-1, LoggerNameUtil.getFirstSeparatorIndexOf("noSeparator")); + } + + @Test + public void firstSeparatorSearchStartsAtTheBeginning() { + // a separator at index 0 is found, i.e. the search is not started past it + assertEquals(0, LoggerNameUtil.getFirstSeparatorIndexOf(".root")); + assertEquals(LoggerNameUtil.getSeparatorIndexOf("a.b.c", 0), LoggerNameUtil.getFirstSeparatorIndexOf("a.b.c")); + } + + @Test + public void utilityClassKeepsItsPublicConstructor() { + assertNotNull(new LoggerNameUtil()); + } + private List computeNameParts(String loggerName) { List partList = new ArrayList(); From cf6578f51dd83ba78f46d568f680300b0731b3f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:56:10 +0000 Subject: [PATCH 57/79] test: pin status levels and MDC key-view semantics in classic tests Strengthen tests that let mutations survive: LoggerContextListenerAction's info statuses, RootLoggerAction's "Setting level of ROOT logger" status (and a root without a level attribute), LogbackMDCAdapter.getKeys() marking the map as handed out, and get(null) ignoring a null key that setContextMap() copied in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../LoggerContextListenerActionTest.java | 10 ++++++-- .../joran/action/RootLoggerActionTest.java | 15 ++++++++++++ .../classic/util/LogbackMDCAdapterTest.java | 23 ++++++++++++++++++- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/logback-android/src/test/java/ch/qos/logback/classic/joran/action/LoggerContextListenerActionTest.java b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/LoggerContextListenerActionTest.java index e5ec61aae..bc3bfe9d7 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/joran/action/LoggerContextListenerActionTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/LoggerContextListenerActionTest.java @@ -99,15 +99,21 @@ public void contextAwareLifeCycleListenerIsGivenTheContextStartedAndRegistered() StartableContextListener listener = (StartableContextListener) ic.peekObject(); assertSame(context, listener.getContext()); assertFalse(listener.isStarted()); + List statuses = statuses(); + assertEquals(statuses.toString(), 1, statuses.size()); + assertEquals(Status.INFO, statuses.get(0).getLevel()); assertEquals("Adding LoggerContextListener of type [" + StartableContextListener.class.getName() - + "] to the object stack", statuses().get(0).getMessage()); + + "] to the object stack", statuses.get(0).getMessage()); action.end(ic, "loggerContextListener"); assertTrue(listener.isStarted()); assertEquals(Collections.singletonList(listener), context.getCopyOfListenerList()); assertTrue(ic.isEmpty()); - assertEquals(1, statusCount("Starting LoggerContextListener")); + statuses = statuses(); + assertEquals(statuses.toString(), 2, statuses.size()); + assertEquals(Status.INFO, statuses.get(1).getLevel()); + assertEquals("Starting LoggerContextListener", statuses.get(1).getMessage()); } @Test diff --git a/logback-android/src/test/java/ch/qos/logback/classic/joran/action/RootLoggerActionTest.java b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/RootLoggerActionTest.java index 9de451474..c79c29720 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/joran/action/RootLoggerActionTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/joran/action/RootLoggerActionTest.java @@ -55,6 +55,21 @@ public void beginSetsTheRootLevelAndPushesTheRootLogger() { assertFalse(action.inError); assertSame(root, ic.peekObject()); assertEquals(Level.ERROR, root.getLevel()); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(statuses.toString(), 1, statuses.size()); + assertEquals(Status.INFO, statuses.get(0).getLevel()); + assertEquals("Setting level of ROOT logger to ERROR", statuses.get(0).getMessage()); + } + + @Test + public void beginWithoutLevelLeavesTheRootLevelAlone() { + root.setLevel(Level.WARN); + + action.begin(ic, "root", new AttributesImpl()); + + assertSame(root, ic.peekObject()); + assertEquals(Level.WARN, root.getLevel()); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); } @Test diff --git a/logback-android/src/test/java/ch/qos/logback/classic/util/LogbackMDCAdapterTest.java b/logback-android/src/test/java/ch/qos/logback/classic/util/LogbackMDCAdapterTest.java index 87c1de4f8..5739956ac 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/util/LogbackMDCAdapterTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/util/LogbackMDCAdapterTest.java @@ -25,10 +25,12 @@ import java.util.ArrayDeque; import java.util.Arrays; +import java.util.Collections; import java.util.Deque; import java.util.HashMap; import java.util.HashSet; import java.util.Map; +import java.util.Set; import java.util.concurrent.CountDownLatch; import org.junit.Test; @@ -139,7 +141,13 @@ public void removeAfterGetPropertyMapLeavesTheReturnedMapIntact() { @Test public void getWithNullKeyReturnsNullEvenWithAMap() { - mdcAdapter.put("k0", "v0"); + // put() rejects a null key, but setContextMap() copies whatever it is given + Map contextMap = new HashMap(); + contextMap.put("k0", "v0"); + contextMap.put(null, "valueOfNullKey"); + mdcAdapter.setContextMap(contextMap); + + assertEquals("v0", mdcAdapter.get("k0")); assertNull(mdcAdapter.get(null)); } @@ -155,6 +163,19 @@ public void getKeysReturnsTheKeysOfTheMap() { assertEquals(new HashSet(Arrays.asList("k0", "k1")), mdcAdapter.getKeys()); } + @Test + public void keysHandedOutByGetKeysAreNotChangedByLaterWrites() { + mdcAdapter.put("k0", "v0"); + Set keys = mdcAdapter.getKeys(); + + // getKeys() hands out a view of the map, so the next writes must copy it + mdcAdapter.put("k1", "v1"); + mdcAdapter.remove("k0"); + + assertEquals(Collections.singleton("k0"), keys); + assertEquals(Collections.singleton("k1"), mdcAdapter.getKeys()); + } + @Test public void getCopyOfContextMapIsNullWithoutAMap() { assertNull(mdcAdapter.getCopyOfContextMap()); From 36c69a78a83cbe88078578922ae537339f2b7420 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:19:55 +0000 Subject: [PATCH 58/79] test: cover core boolex, encoder, filter, hook, read and status classes Close the JaCoCo gaps of EvaluationException, EventEvaluatorBase, EncoderBase, LayoutWrappingEncoder, AbstractMatcherFilter, EvaluatorFilter, Filter, DefaultShutdownHook, ShutdownHookBase, CyclicBufferAppender, OnErrorConsoleStatusListener, OnPrintStreamStatusListenerBase, StatusBase and StatusUtil. The tests pin: - EvaluatorFilter: start fails with an error status without an evaluator; decide() is NEUTRAL unless both filter and evaluator are started, maps matches/mismatches to onMatch/onMismatch, and turns an EvaluationException into NEUTRAL plus an error status carrying it. - LayoutWrappingEncoder: null header/footer without a layout, header and footer composition in the configured charset, isStarted() always false, and the deprecated immediateFlush being pushed to an OutputStreamAppender parent (warning) or reported as an error for any other parent. - DefaultShutdownHook/ShutdownHookBase: the delay is reported and slept, an interrupted sleep still stops the context, and only a ContextBase gets stopped. - OnPrintStreamStatusListenerBase: prefix, stop, and the retrospective threshold (zero disables replay; older statuses are skipped). - StatusBase equals/hashCode/toString edge cases, StatusUtil null status managers/lists, add* helpers, error/warning-free checks and cause-chain exception lookup, plus lifecycle getters/setters of the remaining classes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../core/boolex/EvaluationExceptionTest.java | 53 ++++++ .../core/boolex/EventEvaluatorBaseTest.java | 72 +++++++ .../logback/core/encoder/EncoderBaseTest.java | 48 +++++ .../encoder/LayoutWrappingEncoderTest.java | 176 ++++++++++++++++++ .../filter/AbstractMatcherFilterTest.java | 58 ++++++ .../core/filter/EvaluatorFilterTest.java | 150 +++++++++++++++ .../qos/logback/core/filter/FilterTest.java | 71 +++++++ .../core/hook/DefaultShutdownHookTest.java | 102 ++++++++++ .../core/hook/ShutdownHookBaseTest.java | 71 +++++++ .../core/read/CyclicBufferAppenderTest.java | 54 ++++++ .../OnErrorConsoleStatusListenerTest.java | 64 +++++++ .../OnPrintStreamStatusListenerBaseTest.java | 176 ++++++++++++++++++ .../logback/core/status/StatusBaseTest.java | 89 +++++++++ .../logback/core/status/StatusUtilTest.java | 152 +++++++++++++++ 14 files changed, 1336 insertions(+) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/boolex/EvaluationExceptionTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/boolex/EventEvaluatorBaseTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/encoder/EncoderBaseTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/encoder/LayoutWrappingEncoderTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/filter/AbstractMatcherFilterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/filter/EvaluatorFilterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/filter/FilterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/hook/DefaultShutdownHookTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/hook/ShutdownHookBaseTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/status/OnErrorConsoleStatusListenerTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/status/OnPrintStreamStatusListenerBaseTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/core/boolex/EvaluationExceptionTest.java b/logback-android/src/test/java/ch/qos/logback/core/boolex/EvaluationExceptionTest.java new file mode 100644 index 000000000..37336e9bc --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/boolex/EvaluationExceptionTest.java @@ -0,0 +1,53 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.boolex; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +public class EvaluationExceptionTest { + + @Test + public void messageConstructorKeepsMessageAndHasNoCause() { + EvaluationException e = new EvaluationException("bad expression"); + + assertEquals("bad expression", e.getMessage()); + assertNull(e.getCause()); + } + + @Test + public void messageAndCauseConstructorKeepsBoth() { + IllegalStateException cause = new IllegalStateException("root"); + + EvaluationException e = new EvaluationException("bad expression", cause); + + assertEquals("bad expression", e.getMessage()); + assertSame(cause, e.getCause()); + } + + @Test + public void causeConstructorKeepsCauseAndDerivesMessageFromIt() { + IllegalStateException cause = new IllegalStateException("root"); + + EvaluationException e = new EvaluationException(cause); + + assertSame(cause, e.getCause()); + assertEquals(cause.toString(), e.getMessage()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/boolex/EventEvaluatorBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/boolex/EventEvaluatorBaseTest.java new file mode 100644 index 000000000..9c0c69802 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/boolex/EventEvaluatorBaseTest.java @@ -0,0 +1,72 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.boolex; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class EventEvaluatorBaseTest { + + static class ConstantEvaluator extends EventEvaluatorBase { + @Override + public boolean evaluate(Object event) { + return true; + } + } + + private final ConstantEvaluator evaluator = new ConstantEvaluator(); + + @Test + public void nameIsNullUntilSet() { + assertNull(evaluator.getName()); + } + + @Test + public void setNameStoresTheName() { + evaluator.setName("first"); + + assertEquals("first", evaluator.getName()); + } + + @Test + public void settingTheNameTwiceIsRejectedAndKeepsTheFirstName() { + evaluator.setName("first"); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> evaluator.setName("second")); + + assertEquals("name has been already set", e.getMessage()); + assertEquals("first", evaluator.getName()); + } + + @Test + public void isNotStartedInitially() { + assertFalse(evaluator.isStarted()); + } + + @Test + public void startAndStopToggleStartedState() { + evaluator.start(); + assertTrue(evaluator.isStarted()); + + evaluator.stop(); + assertFalse(evaluator.isStarted()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/encoder/EncoderBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/encoder/EncoderBaseTest.java new file mode 100644 index 000000000..2ba9a4517 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/encoder/EncoderBaseTest.java @@ -0,0 +1,48 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.encoder; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class EncoderBaseTest { + + // NopEncoder inherits the lifecycle of EncoderBase unchanged + private final EncoderBase encoder = new NopEncoder(); + + @Test + public void isNotStartedInitially() { + assertFalse(encoder.isStarted()); + } + + @Test + public void startMarksEncoderStarted() { + encoder.start(); + + assertTrue(encoder.isStarted()); + } + + @Test + public void stopMarksEncoderStopped() { + encoder.start(); + + encoder.stop(); + + assertFalse(encoder.isStarted()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/encoder/LayoutWrappingEncoderTest.java b/logback-android/src/test/java/ch/qos/logback/core/encoder/LayoutWrappingEncoderTest.java new file mode 100644 index 000000000..3bf6e791d --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/encoder/LayoutWrappingEncoderTest.java @@ -0,0 +1,176 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.encoder; + +import java.nio.charset.Charset; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.OutputStreamAppender; +import ch.qos.logback.core.layout.DummyLayout; +import ch.qos.logback.core.read.ListAppender; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class LayoutWrappingEncoderTest { + + private static final Charset UTF_16BE = Charset.forName("UTF-16BE"); + + private final Context context = new ContextBase(); + private final StatusChecker checker = new StatusChecker(context); + private final LayoutWrappingEncoder encoder = new LayoutWrappingEncoder(); + private final DummyLayout layout = new DummyLayout("body"); + + @Before + public void setUp() { + encoder.setContext(context); + layout.setContext(context); + } + + @Test + public void headerBytesIsNullWithoutLayout() { + assertNull(encoder.getLayout()); + assertNull(encoder.headerBytes()); + } + + @Test + public void footerBytesIsNullWithoutLayout() { + assertNull(encoder.footerBytes()); + } + + @Test + public void headerBytesJoinsFileAndPresentationHeaderAndEndsWithLineSeparator() { + layout.setFileHeader("FH"); + layout.setPresentationHeader("PH"); + encoder.setLayout(layout); + encoder.setCharset(UTF_16BE); + + assertArrayEquals(("FHPH" + CoreConstants.LINE_SEPARATOR).getBytes(UTF_16BE), encoder.headerBytes()); + } + + @Test + public void headerBytesIsEmptyWithoutLineSeparatorWhenLayoutHasNoHeaders() { + encoder.setLayout(layout); + + assertEquals(0, encoder.headerBytes().length); + } + + @Test + public void footerBytesJoinsPresentationAndFileFooter() { + layout.setPresentationFooter("PF"); + layout.setFileFooter("FF"); + encoder.setLayout(layout); + encoder.setCharset(UTF_16BE); + + assertArrayEquals("PFFF".getBytes(UTF_16BE), encoder.footerBytes()); + } + + @Test + public void encodeUsesConfiguredCharset() { + encoder.setLayout(layout); + encoder.setCharset(UTF_16BE); + + assertSame(UTF_16BE, encoder.getCharset()); + assertArrayEquals("body".getBytes(UTF_16BE), encoder.encode("ignored")); + } + + @Test + public void encodeWithoutCharsetUsesPlatformDefault() { + encoder.setLayout(layout); + + assertNull(encoder.getCharset()); + assertArrayEquals("body".getBytes(), encoder.encode("ignored")); + } + + @Test + public void isStartedAlwaysReportsFalseAlthoughStartAndStopToggleTheFlag() { + encoder.start(); + assertTrue(encoder.started); + assertFalse(encoder.isStarted()); + + encoder.stop(); + assertFalse(encoder.started); + assertFalse(encoder.isStarted()); + } + + @Test + public void startWithoutImmediateFlushLeavesParentUntouchedAndReportsNothing() { + OutputStreamAppender parent = new OutputStreamAppender(); + parent.setImmediateFlush(false); + encoder.setParent(parent); + + encoder.start(); + + assertFalse(parent.isImmediateFlush()); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } + + @Test + public void setImmediateFlushWarnsThatThePropertyBelongsToTheAppender() { + encoder.setImmediateFlush(false); + + checker.assertContainsMatch(Status.WARN, "As of version 1.2.0 \"immediateFlush\" property should be set within the enclosing Appender."); + checker.assertContainsMatch(Status.WARN, "Please move \"immediateFlush\" property into the enclosing appender."); + } + + @Test + public void startPropagatesImmediateFlushToOutputStreamAppenderParent() { + OutputStreamAppender parent = new OutputStreamAppender(); + assertTrue(parent.isImmediateFlush()); + encoder.setParent(parent); + encoder.setImmediateFlush(false); + + encoder.start(); + + assertFalse(parent.isImmediateFlush()); + assertTrue(encoder.started); + checker.assertContainsMatch(Status.WARN, "Setting the \"immediateFlush\" property of the enclosing appender to false"); + checker.assertIsErrorFree(); + } + + @Test + public void startReportsErrorWhenParentIsNotAnOutputStreamAppender() { + encoder.setParent(new ListAppender()); + encoder.setImmediateFlush(true); + + encoder.start(); + + assertTrue(encoder.started); + checker.assertContainsMatch(Status.ERROR, "Could not set the \"immediateFlush\" property of the enclosing appender."); + checker.assertNoMatch("Setting the \"immediateFlush\""); + } + + @Test + public void startReportsErrorWhenThereIsNoParent() { + encoder.setImmediateFlush(true); + + encoder.start(); + + assertTrue(encoder.started); + checker.assertContainsMatch(Status.ERROR, "Could not set the \"immediateFlush\" property of the enclosing appender."); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/filter/AbstractMatcherFilterTest.java b/logback-android/src/test/java/ch/qos/logback/core/filter/AbstractMatcherFilterTest.java new file mode 100644 index 000000000..123cc341b --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/filter/AbstractMatcherFilterTest.java @@ -0,0 +1,58 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.filter; + +import org.junit.Test; + +import ch.qos.logback.core.spi.FilterReply; + +import static org.junit.Assert.assertEquals; + +public class AbstractMatcherFilterTest { + + static class MatchesTrueFilter extends AbstractMatcherFilter { + @Override + public FilterReply decide(Boolean event) { + return event ? onMatch : onMismatch; + } + } + + private final MatchesTrueFilter filter = new MatchesTrueFilter(); + + @Test + public void onMatchAndOnMismatchDefaultToNeutral() { + assertEquals(FilterReply.NEUTRAL, filter.getOnMatch()); + assertEquals(FilterReply.NEUTRAL, filter.getOnMismatch()); + } + + @Test + public void setOnMatchIsReturnedByGetterAndUsedForMatches() { + filter.setOnMatch(FilterReply.ACCEPT); + + assertEquals(FilterReply.ACCEPT, filter.getOnMatch()); + assertEquals(FilterReply.NEUTRAL, filter.getOnMismatch()); + assertEquals(FilterReply.ACCEPT, filter.decide(true)); + } + + @Test + public void setOnMismatchIsReturnedByGetterAndUsedForMismatches() { + filter.setOnMismatch(FilterReply.DENY); + + assertEquals(FilterReply.DENY, filter.getOnMismatch()); + assertEquals(FilterReply.NEUTRAL, filter.getOnMatch()); + assertEquals(FilterReply.DENY, filter.decide(false)); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/filter/EvaluatorFilterTest.java b/logback-android/src/test/java/ch/qos/logback/core/filter/EvaluatorFilterTest.java new file mode 100644 index 000000000..903fc9cd8 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/filter/EvaluatorFilterTest.java @@ -0,0 +1,150 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.filter; + +import java.util.List; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.boolex.EvaluationException; +import ch.qos.logback.core.boolex.EventEvaluatorBase; +import ch.qos.logback.core.spi.FilterReply; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class EvaluatorFilterTest { + + static final String FAIL = "fail"; + + /** + * Matches the event "match", throws an {@link EvaluationException} for the + * event {@link #FAIL} and does not match anything else. + */ + static class EvaluatorFilterTestEvaluator extends EventEvaluatorBase { + final EvaluationException failure = new EvaluationException("boom"); + + @Override + public boolean evaluate(String event) throws EvaluationException { + if (FAIL.equals(event)) { + throw failure; + } + return "match".equals(event); + } + } + + private final Context context = new ContextBase(); + private final StatusChecker checker = new StatusChecker(context); + private final EvaluatorFilter filter = new EvaluatorFilter(); + private final EvaluatorFilterTestEvaluator evaluator = new EvaluatorFilterTestEvaluator(); + + @Before + public void setUp() { + filter.setContext(context); + filter.setName("evalFilter"); + filter.setOnMatch(FilterReply.ACCEPT); + filter.setOnMismatch(FilterReply.DENY); + evaluator.setContext(context); + evaluator.setName("myEvaluator"); + } + + @Test + public void evaluatorIsNullUntilSet() { + assertNull(filter.getEvaluator()); + } + + @Test + public void setEvaluatorIsReturnedByGetter() { + filter.setEvaluator(evaluator); + + assertSame(evaluator, filter.getEvaluator()); + } + + @Test + public void startWithoutEvaluatorReportsErrorAndDoesNotStart() { + filter.start(); + + assertFalse(filter.isStarted()); + checker.assertContainsMatch(Status.ERROR, "No evaluator set for filter evalFilter"); + } + + @Test + public void startWithEvaluatorStartsWithoutErrors() { + filter.setEvaluator(evaluator); + + filter.start(); + + assertTrue(filter.isStarted()); + checker.assertIsErrorFree(); + } + + @Test + public void decideIsNeutralWhenFilterIsNotStarted() { + filter.setEvaluator(evaluator); + evaluator.start(); + + assertEquals(FilterReply.NEUTRAL, filter.decide("match")); + assertEquals(FilterReply.NEUTRAL, filter.decide("other")); + } + + @Test + public void decideIsNeutralWhenEvaluatorIsNotStarted() { + filter.setEvaluator(evaluator); + filter.start(); + + assertEquals(FilterReply.NEUTRAL, filter.decide("match")); + assertEquals(FilterReply.NEUTRAL, filter.decide("other")); + } + + @Test + public void decideReturnsOnMatchWhenEvaluatorMatches() { + startFilterAndEvaluator(); + + assertEquals(FilterReply.ACCEPT, filter.decide("match")); + } + + @Test + public void decideReturnsOnMismatchWhenEvaluatorDoesNotMatch() { + startFilterAndEvaluator(); + + assertEquals(FilterReply.DENY, filter.decide("other")); + } + + @Test + public void decideIsNeutralAndReportsErrorWhenEvaluatorThrows() { + startFilterAndEvaluator(); + + assertEquals(FilterReply.NEUTRAL, filter.decide(FAIL)); + + checker.assertContainsMatch(Status.ERROR, "Evaluator myEvaluator threw an exception"); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertSame(evaluator.failure, statuses.get(statuses.size() - 1).getThrowable()); + } + + private void startFilterAndEvaluator() { + filter.setEvaluator(evaluator); + evaluator.start(); + filter.start(); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/filter/FilterTest.java b/logback-android/src/test/java/ch/qos/logback/core/filter/FilterTest.java new file mode 100644 index 000000000..fa8ea106a --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/filter/FilterTest.java @@ -0,0 +1,71 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.filter; + +import org.junit.Test; + +import ch.qos.logback.core.spi.FilterReply; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class FilterTest { + + static class DenyAllFilter extends Filter { + @Override + public FilterReply decide(Object event) { + return FilterReply.DENY; + } + } + + private final Filter filter = new DenyAllFilter(); + + @Test + public void isNotStartedInitially() { + assertFalse(filter.isStarted()); + } + + @Test + public void startAndStopToggleStartedState() { + filter.start(); + assertTrue(filter.isStarted()); + + filter.stop(); + assertFalse(filter.isStarted()); + } + + @Test + public void nameIsNullUntilSet() { + assertNull(filter.getName()); + } + + @Test + public void setNameStoresTheName() { + filter.setName("myFilter"); + + assertEquals("myFilter", filter.getName()); + } + + @Test + public void setNameCanReplaceThePreviousName() { + filter.setName("first"); + filter.setName("second"); + + assertEquals("second", filter.getName()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/hook/DefaultShutdownHookTest.java b/logback-android/src/test/java/ch/qos/logback/core/hook/DefaultShutdownHookTest.java new file mode 100644 index 000000000..f9c580111 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/hook/DefaultShutdownHookTest.java @@ -0,0 +1,102 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.hook; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; +import ch.qos.logback.core.util.Duration; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class DefaultShutdownHookTest { + + private final ContextBase context = new ContextBase(); + private final StatusChecker checker = new StatusChecker(context); + private final DefaultShutdownHook hook = new DefaultShutdownHook(); + + @Before + public void setUp() { + hook.setContext(context); + context.start(); + assertTrue(context.isStarted()); + } + + @After + public void tearDown() { + // never leak an interrupt flag into other tests + Thread.interrupted(); + context.stop(); + } + + @Test + public void delayDefaultsToZero() { + assertSame(DefaultShutdownHook.DEFAULT_DELAY, hook.getDelay()); + assertEquals(0, hook.getDelay().getMilliseconds()); + } + + @Test + public void setDelayIsReturnedByGetter() { + Duration delay = Duration.buildBySeconds(3); + + hook.setDelay(delay); + + assertSame(delay, hook.getDelay()); + } + + @Test + public void runWithoutDelayStopsContextWithoutSleeping() { + hook.run(); + + assertFalse(context.isStarted()); + checker.assertContainsMatch(Status.INFO, "Logback context being closed via shutdown hook"); + checker.assertNoMatch("Sleeping for"); + } + + @Test + public void runWithDelayReportsSleepThenStopsContext() { + hook.setDelay(Duration.buildByMilliseconds(1)); + + hook.run(); + + assertFalse(context.isStarted()); + checker.assertContainsMatch(Status.INFO, "Sleeping for 1 milliseconds"); + checker.assertContainsMatch(Status.INFO, "Logback context being closed via shutdown hook"); + } + + @Test + public void runStillStopsContextWhenInterruptedWhileSleeping() { + // a delay long enough that the test could only finish because the sleep + // was cut short by the pending interrupt + hook.setDelay(Duration.buildByHours(1)); + Thread.currentThread().interrupt(); + + hook.run(); + + assertFalse(context.isStarted()); + checker.assertContainsMatch(Status.INFO, "Sleeping for 1 hours"); + checker.assertContainsMatch(Status.INFO, "Logback context being closed via shutdown hook"); + // the InterruptedException is swallowed; sleep() cleared the interrupt flag + assertFalse(Thread.currentThread().isInterrupted()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/hook/ShutdownHookBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/hook/ShutdownHookBaseTest.java new file mode 100644 index 000000000..8bf814249 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/hook/ShutdownHookBaseTest.java @@ -0,0 +1,71 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.hook; + +import org.junit.Test; + +import ch.qos.logback.core.BasicStatusManager; +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.spi.LifeCycle; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; +import ch.qos.logback.core.status.StatusManager; + +import static org.junit.Assert.assertFalse; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +public class ShutdownHookBaseTest { + + /** A hook that stops the context as soon as it runs. */ + static class ImmediateShutdownHook extends ShutdownHookBase { + @Override + public void run() { + stop(); + } + } + + private final ImmediateShutdownHook hook = new ImmediateShutdownHook(); + + @Test + public void stopStopsAContextBase() { + ContextBase context = new ContextBase(); + context.start(); + hook.setContext(context); + + hook.run(); + + assertFalse(context.isStarted()); + new StatusChecker(context).assertContainsMatch(Status.INFO, "Logback context being closed via shutdown hook"); + } + + @Test + public void stopOnlyReportsForAContextThatIsNotAContextBase() { + StatusManager statusManager = new BasicStatusManager(); + Context context = mock(Context.class, withSettings().extraInterfaces(LifeCycle.class)); + when(context.getStatusManager()).thenReturn(statusManager); + hook.setContext(context); + + hook.run(); + + new StatusChecker(statusManager).assertContainsMatch(Status.INFO, "Logback context being closed via shutdown hook"); + verify((LifeCycle) context, never()).stop(); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/read/CyclicBufferAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/core/read/CyclicBufferAppenderTest.java index 36f3f9c5e..c198b5463 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/read/CyclicBufferAppenderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/read/CyclicBufferAppenderTest.java @@ -19,6 +19,8 @@ import org.junit.Test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; public class CyclicBufferAppenderTest { @@ -47,5 +49,57 @@ public void genericGet() { assertEquals("Some string", foo); } + @Test + public void maxSizeDefaultsTo512() { + assertEquals(512, new CyclicBufferAppender().getMaxSize()); + } + + @Test + public void setMaxSizeBoundsTheBufferCreatedOnStart() { + CyclicBufferAppender appender = new CyclicBufferAppender(); + appender.setMaxSize(2); + assertEquals(2, appender.getMaxSize()); + appender.start(); + + appender.append("a"); + appender.append("b"); + appender.append("c"); + + assertEquals(2, appender.getLength()); + assertEquals("b", appender.get(0)); + assertEquals("c", appender.get(1)); + } + + @Test + public void stopDiscardsTheBuffer() { + cyclicBufferAppender.append("foobar"); + + cyclicBufferAppender.stop(); + + assertFalse(cyclicBufferAppender.isStarted()); + assertNull(cyclicBufferAppender.cb); + assertEquals(0, cyclicBufferAppender.getLength()); + assertNull(cyclicBufferAppender.get(0)); + } + + @Test + public void appendIsIgnoredWhenNotStarted() { + CyclicBufferAppender appender = new CyclicBufferAppender(); + + // no buffer exists before start(); append must not touch it + appender.append("ignored"); + + appender.start(); + assertEquals(0, appender.getLength()); + } + + @Test + public void getLengthAndGetAreEmptyBeforeStart() { + CyclicBufferAppender appender = new CyclicBufferAppender(); + + assertEquals(0, appender.getLength()); + assertNull(appender.get(0)); + } + } diff --git a/logback-android/src/test/java/ch/qos/logback/core/status/OnErrorConsoleStatusListenerTest.java b/logback-android/src/test/java/ch/qos/logback/core/status/OnErrorConsoleStatusListenerTest.java new file mode 100644 index 000000000..3d519098a --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/status/OnErrorConsoleStatusListenerTest.java @@ -0,0 +1,64 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.status; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.CoreConstants; + +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class OnErrorConsoleStatusListenerTest { + + private final PrintStream originalErr = System.err; + private final ByteArrayOutputStream captured = new ByteArrayOutputStream(); + private PrintStream capturingErr; + + private final OnErrorConsoleStatusListener listener = new OnErrorConsoleStatusListener(); + + @Before + public void setUp() { + capturingErr = OnPrintStreamStatusListenerBaseTest.newUtf8PrintStream(captured); + System.setErr(capturingErr); + } + + @After + public void tearDown() { + System.setErr(originalErr); + capturingErr.close(); + } + + @Test + public void printStreamIsTheCurrentSystemErr() { + assertSame(capturingErr, listener.getPrintStream()); + } + + @Test + public void startedListenerPrintsStatusesToSystemErr() throws Exception { + listener.start(); + + listener.addStatusEvent(new WarnStatus("hello", "origin")); + + String output = captured.toString("UTF-8"); + assertTrue(output, output.endsWith("|-WARN in origin - hello" + CoreConstants.LINE_SEPARATOR)); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/status/OnPrintStreamStatusListenerBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/status/OnPrintStreamStatusListenerBaseTest.java new file mode 100644 index 000000000..9adca6102 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/status/OnPrintStreamStatusListenerBaseTest.java @@ -0,0 +1,176 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.status; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.io.UnsupportedEncodingException; + +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.util.StatusPrinter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class OnPrintStreamStatusListenerBaseTest { + + private static final long ONE_HOUR = 60L * 60L * 1000L; + + /** Prints to an in-memory stream so that the output can be inspected. */ + static class CapturingPrintStreamStatusListener extends OnPrintStreamStatusListenerBase { + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + final PrintStream printStream = newUtf8PrintStream(bytes); + + @Override + protected PrintStream getPrintStream() { + return printStream; + } + + String output() { + printStream.flush(); + try { + return bytes.toString("UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } + } + + static PrintStream newUtf8PrintStream(ByteArrayOutputStream bytes) { + try { + return new PrintStream(bytes, true, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } + + private final Context context = new ContextBase(); + private final CapturingPrintStreamStatusListener listener = new CapturingPrintStreamStatusListener(); + + private static String render(Status status) { + StringBuilder sb = new StringBuilder(); + StatusPrinter.buildStr(sb, "", status); + return sb.toString(); + } + + @Test + public void defaults() { + assertNull(listener.getPrefix()); + assertEquals(OnPrintStreamStatusListenerBase.DEFAULT_RETROSPECTIVE, listener.getRetrospective()); + assertEquals(300, listener.getRetrospective()); + assertFalse(listener.isStarted()); + } + + @Test + public void setPrefixIsReturnedByGetter() { + listener.setPrefix("PFX "); + + assertEquals("PFX ", listener.getPrefix()); + } + + @Test + public void setRetrospectiveIsReturnedByGetter() { + listener.setRetrospective(1234); + + assertEquals(1234, listener.getRetrospective()); + } + + @Test + public void statusEventsAreIgnoredBeforeStart() { + listener.addStatusEvent(new InfoStatus("hello", "origin")); + + assertEquals("", listener.output()); + } + + @Test + public void startedListenerPrintsIncomingStatusWithoutPrefixByDefault() { + listener.start(); + InfoStatus status = new InfoStatus("hello", "origin"); + + listener.addStatusEvent(status); + + assertTrue(listener.isStarted()); + assertEquals(render(status), listener.output()); + } + + @Test + public void startedListenerPrintsPrefixBeforeEachStatus() { + listener.setPrefix("PFX "); + listener.start(); + InfoStatus first = new InfoStatus("first", "origin"); + WarnStatus second = new WarnStatus("second", "origin"); + + listener.addStatusEvent(first); + listener.addStatusEvent(second); + + assertEquals("PFX " + render(first) + "PFX " + render(second), listener.output()); + } + + @Test + public void stopMakesTheListenerIgnoreFurtherStatusEvents() { + listener.start(); + listener.stop(); + + listener.addStatusEvent(new InfoStatus("hello", "origin")); + + assertFalse(listener.isStarted()); + assertEquals("", listener.output()); + } + + @Test + public void startWithoutContextPrintsNothing() { + listener.start(); + + assertTrue(listener.isStarted()); + assertEquals("", listener.output()); + } + + @Test + public void startPrintsOnlyStatusesYoungerThanTheRetrospectiveThreshold() { + InfoStatus old = new InfoStatus("old", "origin"); + old.date = 0; // the epoch: far older than the threshold + InfoStatus recent = new InfoStatus("recent", "origin"); + context.getStatusManager().add(old); + context.getStatusManager().add(recent); + listener.setContext(context); + listener.setRetrospective(ONE_HOUR); + + listener.start(); + + assertEquals(render(recent), listener.output()); + } + + @Test + public void startWithZeroRetrospectivePrintsNoPastStatuses() { + InfoStatus recent = new InfoStatus("recent", "origin"); + // a future timestamp is younger than even a zero threshold, so this status + // would be printed if start() looked back at all + recent.date = System.currentTimeMillis() + ONE_HOUR; + context.getStatusManager().add(recent); + listener.setContext(context); + listener.setRetrospective(0); + + listener.start(); + + assertTrue(listener.isStarted()); + assertEquals("", listener.output()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/status/StatusBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/status/StatusBaseTest.java index 6bae99820..42e2fb2e9 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/status/StatusBaseTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/status/StatusBaseTest.java @@ -92,4 +92,93 @@ public void testEffectiveLevel() { } } + public void testRemoveFromStatusWithoutChildrenReturnsFalse() { + InfoStatus status = new InfoStatus("testing", this); + + assertFalse(status.remove(new ErrorStatus("error", this))); + assertFalse(status.hasChildren()); + } + + public void testToStringWithoutOriginOmitsOriginPart() { + InfoStatus status = new InfoStatus("testing", null); + + assertEquals("INFO testing", status.toString()); + } + + public void testToStringWithUnknownLevelOmitsLevelName() { + InfoStatus status = new InfoStatus("testing", "origin"); + status.level = 42; + + assertEquals(" in origin - testing", status.toString()); + } + + public void testHashCodeCombinesLevelAndMessage() { + assertEquals(31 * (31 + Status.INFO) + "testing".hashCode(), + new InfoStatus("testing", this).hashCode()); + assertEquals(31 * (31 + Status.WARN) + "testing".hashCode(), + new WarnStatus("testing", this).hashCode()); + } + + public void testHashCodeOfNullMessageDependsOnLevelOnly() { + assertEquals(31 * (31 + Status.ERROR), new ErrorStatus(null, this).hashCode()); + } + + public void testHashCodeIgnoresOriginAndThrowable() { + InfoStatus a = new InfoStatus("testing", "a"); + InfoStatus b = new InfoStatus("testing", "b", new Exception()); + + assertEquals(a.hashCode(), b.hashCode()); + } + + public void testEqualsIsReflexive() { + InfoStatus status = new InfoStatus("testing", this); + + assertTrue(status.equals(status)); + } + + public void testNotEqualToNull() { + assertFalse(new InfoStatus("testing", this).equals(null)); + } + + public void testNotEqualToStatusOfAnotherClassWithSameLevelAndMessage() { + InfoStatus status = new InfoStatus("testing", this); + InfoStatus subclassStatus = new InfoStatus("testing", this) { + }; + + assertEquals(status.getLevel(), subclassStatus.getLevel()); + assertFalse(status.equals(subclassStatus)); + } + + public void testNotEqualWhenLevelsDiffer() { + InfoStatus status = new InfoStatus("testing", this); + InfoStatus other = new InfoStatus("testing", this); + other.level = Status.WARN; + + assertFalse(status.equals(other)); + } + + public void testEqualWhenLevelAndMessageMatchRegardlessOfOriginAndThrowable() { + InfoStatus a = new InfoStatus("testing", "a"); + InfoStatus b = new InfoStatus("testing", "b", new Exception()); + + assertTrue(a.equals(b)); + assertTrue(b.equals(a)); + } + + public void testNotEqualWhenMessagesDiffer() { + assertFalse(new InfoStatus("testing", this).equals(new InfoStatus("other", this))); + } + + public void testEqualWhenBothMessagesAreNull() { + assertTrue(new InfoStatus(null, this).equals(new InfoStatus(null, this))); + } + + public void testNotEqualWhenOnlyOneMessageIsNull() { + InfoStatus withNull = new InfoStatus(null, this); + InfoStatus withMessage = new InfoStatus("testing", this); + + assertFalse(withNull.equals(withMessage)); + assertFalse(withMessage.equals(withNull)); + } + } diff --git a/logback-android/src/test/java/ch/qos/logback/core/status/StatusUtilTest.java b/logback-android/src/test/java/ch/qos/logback/core/status/StatusUtilTest.java index 96dc4d302..be87c10df 100755 --- a/logback-android/src/test/java/ch/qos/logback/core/status/StatusUtilTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/status/StatusUtilTest.java @@ -15,13 +15,21 @@ */ package ch.qos.logback.core.status; +import java.io.IOException; +import java.util.List; + import ch.qos.logback.core.Context; import ch.qos.logback.core.ContextBase; import ch.qos.logback.core.CoreConstants; import org.junit.Test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** * @author Ceki Gücü @@ -51,4 +59,148 @@ public void statusListShouldReturnLastResetTime() { assertTrue(resetTime <= statusUtil.timeOfLastReset()); } + @Test + public void timeOfLastResetIsNotFoundWhenStatusManagerReturnsNoList() { + StatusManager sm = mock(StatusManager.class); + when(sm.getCopyOfStatusList()).thenReturn(null); + + assertEquals(-1, new StatusUtil(sm).timeOfLastReset()); + } + + @Test + public void contextWithoutStatusManagerHasNoStatusListener() { + Context contextWithoutSm = mock(Context.class); + when(contextWithoutSm.getStatusManager()).thenReturn(null); + + assertFalse(StatusUtil.contextHasStatusListener(contextWithoutSm)); + } + + @Test + public void statusManagerReturningNoListenerListMeansNoStatusListener() { + StatusManager sm = mock(StatusManager.class); + when(sm.getCopyOfStatusListenerList()).thenReturn(null); + Context mockContext = mock(Context.class); + when(mockContext.getStatusManager()).thenReturn(sm); + + assertFalse(StatusUtil.contextHasStatusListener(mockContext)); + } + + @Test + public void contextWithoutRegisteredListenersHasNoStatusListener() { + assertFalse(StatusUtil.contextHasStatusListener(context)); + } + + @Test + public void contextWithRegisteredListenerHasStatusListener() { + context.getStatusManager().add(new NopStatusListener()); + + assertTrue(StatusUtil.contextHasStatusListener(context)); + } + + @Test + public void addStatusAddsToTheStatusManager() { + InfoStatus status = new InfoStatus("hello", this); + + statusUtil.addStatus(status); + + List list = context.getStatusManager().getCopyOfStatusList(); + assertEquals(1, list.size()); + assertSame(status, list.get(0)); + } + + @Test + public void addStatusWithoutStatusManagerIsIgnoredInsteadOfFailing() { + StatusUtil withoutSm = new StatusUtil((StatusManager) null); + + // must not throw a NullPointerException + withoutSm.addStatus(new InfoStatus("hello", this)); + withoutSm.addWarn(this, "warn"); + } + + @Test + public void addInfoAddsInfoStatusWithCallerAsOrigin() { + statusUtil.addInfo("caller", "info message"); + + Status status = onlyStatus(); + assertTrue(status instanceof InfoStatus); + assertEquals(Status.INFO, status.getLevel()); + assertEquals("info message", status.getMessage()); + assertEquals("caller", status.getOrigin()); + assertNull(status.getThrowable()); + } + + @Test + public void addWarnAddsWarnStatusWithCallerAsOrigin() { + statusUtil.addWarn("caller", "warn message"); + + Status status = onlyStatus(); + assertTrue(status instanceof WarnStatus); + assertEquals(Status.WARN, status.getLevel()); + assertEquals("warn message", status.getMessage()); + assertEquals("caller", status.getOrigin()); + assertNull(status.getThrowable()); + } + + @Test + public void addErrorAddsErrorStatusWithCallerAsOriginAndThrowable() { + Exception cause = new Exception("cause"); + + statusUtil.addError("caller", "error message", cause); + + Status status = onlyStatus(); + assertTrue(status instanceof ErrorStatus); + assertEquals(Status.ERROR, status.getLevel()); + assertEquals("error message", status.getMessage()); + assertEquals("caller", status.getOrigin()); + assertSame(cause, status.getThrowable()); + } + + @Test + public void onlyInfoStatusesAreWarningAndErrorFree() { + statusUtil.addInfo(this, "info"); + + assertTrue(statusUtil.isErrorFree(0)); + assertTrue(statusUtil.isWarningOrErrorFree(0)); + } + + @Test + public void warningIsErrorFreeButNotWarningFree() { + statusUtil.addInfo(this, "info"); + statusUtil.addWarn(this, "warn"); + + assertTrue(statusUtil.isErrorFree(0)); + assertFalse(statusUtil.isWarningOrErrorFree(0)); + } + + @Test + public void errorIsNeitherErrorFreeNorWarningFree() { + statusUtil.addError(this, "error", null); + + assertFalse(statusUtil.isErrorFree(0)); + assertFalse(statusUtil.isWarningOrErrorFree(0)); + } + + @Test + public void containsExceptionFindsExceptionTypeInCauseChain() { + statusUtil.addInfo(this, "no throwable"); + statusUtil.addError(this, "wrapped", new IOException("outer", new IllegalStateException("inner"))); + + assertTrue(statusUtil.containsException(IOException.class)); + assertTrue(statusUtil.containsException(IllegalStateException.class)); + } + + @Test + public void containsExceptionIsFalseWhenNoStatusCarriesThatType() { + statusUtil.addInfo(this, "no throwable"); + statusUtil.addError(this, "wrapped", new IOException("outer", new IllegalStateException("inner"))); + + assertFalse(statusUtil.containsException(ArithmeticException.class)); + } + + private Status onlyStatus() { + List list = context.getStatusManager().getCopyOfStatusList(); + assertEquals(1, list.size()); + return list.get(0); + } + } From da0432a0288176747764741fc6bad9fa2a81c018 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:01:30 +0000 Subject: [PATCH 59/79] test: fail fast if DefaultShutdownHook's interrupted sleep regresses Guard the interrupted-sleep test with a JUnit timeout so that a regression (the pending interrupt no longer cutting the one-hour delay short) fails in 30 seconds instead of blocking the build for an hour. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../ch/qos/logback/core/hook/DefaultShutdownHookTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/logback-android/src/test/java/ch/qos/logback/core/hook/DefaultShutdownHookTest.java b/logback-android/src/test/java/ch/qos/logback/core/hook/DefaultShutdownHookTest.java index f9c580111..7ee47fd17 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/hook/DefaultShutdownHookTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/hook/DefaultShutdownHookTest.java @@ -84,7 +84,9 @@ public void runWithDelayReportsSleepThenStopsContext() { checker.assertContainsMatch(Status.INFO, "Logback context being closed via shutdown hook"); } - @Test + // the timeout makes a regression (the interrupt no longer cutting the sleep + // short) fail fast instead of blocking the build for the whole delay + @Test(timeout = 30000) public void runStillStopsContextWhenInterruptedWhileSleeping() { // a delay long enough that the test could only finish because the sleep // was cut short by the pending interrupt From c674d892536eacd14fa1da23bf74f78742463bfe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:31:14 +0000 Subject: [PATCH 60/79] refactor: let SYSTEM share the default branch of ActionUtil's scope switches setProperty() and setProperties() switch over every ActionUtil.Scope constant, so the implicit default branch of each switch can never be taken and the 100% branch-coverage gate can never be met for them. It cannot be reached from a test either: depending on the javac version CI builds with, the switch dispatches on Scope.ordinal() directly (JDK 21) or through a synthetic $SwitchMap array (JDK 17), and forcing an unmapped value means tampering with that compiler-specific code. Label the SYSTEM case "default" too. Both labels jump to the same code, so JaCoCo counts three branches (one per constant), all reachable. Behavior is unchanged for every Scope, and a null scope still throws NullPointerException. ActionUtilTest pins what each scope writes (substitution, context or system properties, and nothing else) for both methods, and that a null scope is rejected. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../logback/core/joran/action/ActionUtil.java | 2 + .../core/joran/action/ActionUtilTest.java | 147 ++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/action/ActionUtilTest.java diff --git a/logback-android/src/main/java/ch/qos/logback/core/joran/action/ActionUtil.java b/logback-android/src/main/java/ch/qos/logback/core/joran/action/ActionUtil.java index d260d5163..b7c0edb97 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/joran/action/ActionUtil.java +++ b/logback-android/src/main/java/ch/qos/logback/core/joran/action/ActionUtil.java @@ -50,6 +50,7 @@ static public void setProperty(InterpretationContext ic, String key, String valu ic.getContext().putProperty(key, value); break; case SYSTEM: + default: // every Scope has a case; sharing SYSTEM's branch leaves none unreachable OptionHelper.setSystemProperty(ic, key, value); } } @@ -73,6 +74,7 @@ static public void setProperties(InterpretationContext ic, Properties props, cu.addProperties(props); break; case SYSTEM: + default: // every Scope has a case; sharing SYSTEM's branch leaves none unreachable OptionHelper.setSystemProperties(ic, props); } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/ActionUtilTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/ActionUtilTest.java new file mode 100644 index 000000000..310d144d7 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/ActionUtilTest.java @@ -0,0 +1,147 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.Properties; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.joran.action.ActionUtil.Scope; +import ch.qos.logback.core.joran.spi.InterpretationContext; + +/** + * Tests {@link ActionUtil}. + */ +public class ActionUtilTest { + + static final String KEY = "ActionUtilTest.key"; + static final String OTHER_KEY = "ActionUtilTest.otherKey"; + + Context context = new ContextBase(); + InterpretationContext ic = new InterpretationContext(context, null); + + @Before + public void setUp() { + System.clearProperty(KEY); + System.clearProperty(OTHER_KEY); + } + + @After + public void tearDown() { + System.clearProperty(KEY); + System.clearProperty(OTHER_KEY); + } + + @Test + public void stringToScopeIsCaseInsensitiveAndDefaultsToLocal() { + assertEquals(Scope.SYSTEM, ActionUtil.stringToScope("system")); + assertEquals(Scope.SYSTEM, ActionUtil.stringToScope("SYSTEM")); + assertEquals(Scope.CONTEXT, ActionUtil.stringToScope("Context")); + assertEquals(Scope.LOCAL, ActionUtil.stringToScope("local")); + assertEquals(Scope.LOCAL, ActionUtil.stringToScope("bogus")); + assertEquals(Scope.LOCAL, ActionUtil.stringToScope(null)); + } + + @Test + public void setPropertyInLocalScopeOnlyAddsASubstitutionProperty() { + ActionUtil.setProperty(ic, KEY, " v ", Scope.LOCAL); + + assertEquals("v", ic.getCopyOfPropertyMap().get(KEY)); + assertNull(context.getProperty(KEY)); + assertNull(System.getProperty(KEY)); + } + + @Test + public void setPropertyInContextScopeOnlySetsAContextProperty() { + ActionUtil.setProperty(ic, KEY, "v", Scope.CONTEXT); + + assertEquals("v", context.getProperty(KEY)); + assertFalse(ic.getCopyOfPropertyMap().containsKey(KEY)); + assertNull(System.getProperty(KEY)); + } + + @Test + public void setPropertyInSystemScopeOnlySetsASystemProperty() { + ActionUtil.setProperty(ic, KEY, "v", Scope.SYSTEM); + + assertEquals("v", System.getProperty(KEY)); + assertNull(context.getProperty(KEY)); + assertFalse(ic.getCopyOfPropertyMap().containsKey(KEY)); + } + + @Test + public void setPropertiesInLocalScopeOnlyAddsSubstitutionProperties() { + ActionUtil.setProperties(ic, twoProperties(), Scope.LOCAL); + + assertEquals("v1", ic.getCopyOfPropertyMap().get(KEY)); + assertEquals("v2", ic.getCopyOfPropertyMap().get(OTHER_KEY)); + assertNull(context.getProperty(KEY)); + assertNull(System.getProperty(KEY)); + } + + @Test + public void setPropertiesInContextScopeOnlySetsContextProperties() { + ActionUtil.setProperties(ic, twoProperties(), Scope.CONTEXT); + + assertEquals("v1", context.getProperty(KEY)); + assertEquals("v2", context.getProperty(OTHER_KEY)); + assertTrue(ic.getCopyOfPropertyMap().isEmpty()); + assertNull(System.getProperty(KEY)); + } + + @Test + public void setPropertiesInSystemScopeOnlySetsSystemProperties() { + ActionUtil.setProperties(ic, twoProperties(), Scope.SYSTEM); + + assertEquals("v1", System.getProperty(KEY)); + assertEquals("v2", System.getProperty(OTHER_KEY)); + assertNull(context.getProperty(KEY)); + assertTrue(ic.getCopyOfPropertyMap().isEmpty()); + } + + @Test + public void nullScopeIsRejected() { + assertThrows(NullPointerException.class, () -> ActionUtil.setProperty(ic, KEY, "v", null)); + assertThrows(NullPointerException.class, () -> ActionUtil.setProperties(ic, twoProperties(), null)); + + assertNull(System.getProperty(KEY)); + assertNull(context.getProperty(KEY)); + assertTrue(ic.getCopyOfPropertyMap().isEmpty()); + } + + @Test + public void canBeInstantiated() { + assertNotNull(new ActionUtil()); + } + + private Properties twoProperties() { + Properties props = new Properties(); + props.setProperty(KEY, "v1"); + props.setProperty(OTHER_KEY, "v2"); + return props; + } +} From 560337b95455f423037a504213f0cea9e3939673 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:31:26 +0000 Subject: [PATCH 61/79] refactor: drop AbstractIncludeAction's unreachable MalformedURLException catch filePathAsURL() converts an existing file with file.toURI().toURL() and caught MalformedURLException with printStackTrace() ("impossible to get here"). A File's URI always has the "file" scheme, whose URL handler is built in and cannot be overridden, so the catch could never run. The only way to reach it from a test would be to mock the construction of java.io.File, which on that thread also intercepts the class loader's own File lookups and can silently drop class-path entries for the rest of the test JVM. Let filePathAsURL()/getInputURL() declare the exception and handle it in begin() alongside JoranException, so that no unreachable catch (and no printStackTrace() to stderr) remains. Every reachable input behaves as before: missing files and directories are still reported by the existence check, and url/resource handling is untouched. AbstractIncludeActionTest pins, through a recording subclass, how file/url/resource sources are resolved (with variable substitution), the missing/duplicate source errors, optional includes staying silent, warn-vs-error classification in handleError(), close(), and the IllegalStateException when a source attribute vanishes after the check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../joran/action/AbstractIncludeAction.java | 17 +- .../action/AbstractIncludeActionTest.java | 394 ++++++++++++++++++ 2 files changed, 399 insertions(+), 12 deletions(-) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/action/AbstractIncludeActionTest.java diff --git a/logback-android/src/main/java/ch/qos/logback/core/joran/action/AbstractIncludeAction.java b/logback-android/src/main/java/ch/qos/logback/core/joran/action/AbstractIncludeAction.java index da8ae1281..6cc8ba183 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/joran/action/AbstractIncludeAction.java +++ b/logback-android/src/main/java/ch/qos/logback/core/joran/action/AbstractIncludeAction.java @@ -20,7 +20,6 @@ import java.io.IOException; import java.io.InputStream; import java.net.MalformedURLException; -import java.net.URI; import java.net.URL; import java.net.UnknownHostException; @@ -69,7 +68,7 @@ public void begin(InterpretationContext ec, String name, Attributes attributes) if (url != null) { processInclude(ec, url); } - } catch (JoranException e) { + } catch (JoranException | MalformedURLException e) { optionalWarning("Error while parsing " + attributeInUse, e); } @@ -140,21 +139,15 @@ private URL resourceAsURL(String resourceAttribute) { return url; } - private URL filePathAsURL(String path) { + private URL filePathAsURL(String path) throws MalformedURLException { File file = new File(path); if (!file.exists() || !file.isFile()) { optionalWarning("File does not exist [" + path + "]", new FileNotFoundException(path)); return null; } - URI uri = file.toURI(); - try { - return uri.toURL(); - } catch (MalformedURLException e) { - // impossible to get here - e.printStackTrace(); - return null; - } + // cannot throw: a file's URI always has the "file" scheme, whose URL handler exists + return file.toURI().toURL(); } protected String getAttributeInUse() { @@ -165,7 +158,7 @@ protected boolean isOptional() { return this.optional; } - private URL getInputURL(InterpretationContext ec, Attributes attributes) { + private URL getInputURL(InterpretationContext ec, Attributes attributes) throws MalformedURLException { String fileAttribute = attributes.getValue(FILE_ATTR); String urlAttribute = attributes.getValue(URL_ATTR); String resourceAttribute = attributes.getValue(RESOURCE_ATTR); diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/AbstractIncludeActionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/AbstractIncludeActionTest.java new file mode 100644 index 000000000..9ecaad070 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/AbstractIncludeActionTest.java @@ -0,0 +1,394 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.xml.sax.Attributes; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.joran.spi.ActionException; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.joran.spi.JoranException; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.util.Loader; + +/** + * Tests {@link AbstractIncludeAction} through a subclass that records what it + * is asked to include. + */ +public class AbstractIncludeActionTest { + + static final String RESOURCE = "asResource/joran/inclusion/includedAsResource.xml"; + + static final String NO_SOURCE_MESSAGE = "One of \"file\", \"resource\" or \"url\" attributes must be set."; + static final String SEVERAL_SOURCES_MESSAGE = "Only one of \"file\", \"resource\" or \"url\" attributes should be set."; + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + Context context = new ContextBase(); + InterpretationContext ic = new InterpretationContext(context, null); + RecordingIncludeAction action = new RecordingIncludeAction(); + DummyAttributes atts = new DummyAttributes(); + + @Before + public void setUp() { + action.setContext(context); + } + + @Test + public void existingFileIsIncluded() throws Exception { + File file = tmp.newFile("included.xml"); + atts.setValue("file", file.getPath()); + + action.begin(ic, "include", atts); + + assertEquals(Collections.singletonList(file.toURI().toURL()), action.includedUrls); + assertEquals(file.getPath(), action.getAttributeInUse()); + assertFalse(action.isOptional()); + assertNoStatus(); + } + + @Test + public void fileAttributeIsSubstitutedBeforeUse() throws Exception { + File file = tmp.newFile("included.xml"); + context.putProperty("includeDir", tmp.getRoot().getPath()); + atts.setValue("file", "${includeDir}/included.xml"); + + action.begin(ic, "include", atts); + + String expectedPath = tmp.getRoot().getPath() + "/included.xml"; + assertEquals(expectedPath, action.getAttributeInUse()); + assertEquals(Collections.singletonList(new File(expectedPath).toURI().toURL()), action.includedUrls); + assertEquals(file.toURI().toURL(), action.includedUrls.get(0)); + } + + @Test + public void missingFileIsAWarning() throws Exception { + String path = new File(tmp.getRoot(), "missing.xml").getPath(); + atts.setValue("file", path); + + action.begin(ic, "include", atts); + + assertTrue(action.includedUrls.isEmpty()); + Status status = assertStatus(Status.WARN, "File does not exist [" + path + "]"); + assertTrue(status.getThrowable() instanceof FileNotFoundException); + assertEquals(path, status.getThrowable().getMessage()); + } + + @Test + public void directoryIsNotIncluded() throws Exception { + String path = tmp.newFolder("dir").getPath(); + atts.setValue("file", path); + + action.begin(ic, "include", atts); + + assertTrue(action.includedUrls.isEmpty()); + assertStatus(Status.WARN, "File does not exist [" + path + "]"); + } + + @Test + public void missingOptionalFileIsSilentlySkipped() throws Exception { + atts.setValue("file", new File(tmp.getRoot(), "missing.xml").getPath()); + atts.setValue("optional", "true"); + + action.begin(ic, "include", atts); + + assertTrue(action.isOptional()); + assertTrue(action.includedUrls.isEmpty()); + assertNoStatus(); + } + + @Test + public void noSourceAttributeIsAnError() throws Exception { + action.begin(ic, "include", atts); + + assertTrue(action.includedUrls.isEmpty()); + assertNull(action.getAttributeInUse()); + Status status = assertStatus(Status.ERROR, NO_SOURCE_MESSAGE); + assertNull(status.getThrowable()); + } + + @Test + public void noSourceAttributeIsSilentWhenOptional() throws Exception { + atts.setValue("optional", "true"); + + action.begin(ic, "include", atts); + + assertTrue(action.includedUrls.isEmpty()); + assertNoStatus(); + } + + @Test + public void severalSourceAttributesAreAnError() throws Exception { + File file = tmp.newFile("included.xml"); + atts.setValue("file", file.getPath()); + atts.setValue("resource", RESOURCE); + + action.begin(ic, "include", atts); + + assertTrue(action.includedUrls.isEmpty()); + assertNull(action.getAttributeInUse()); + Status status = assertStatus(Status.ERROR, SEVERAL_SOURCES_MESSAGE); + assertNull(status.getThrowable()); + } + + @Test + public void severalSourceAttributesAreSilentWhenOptional() throws Exception { + atts.setValue("url", "file:/a.xml"); + atts.setValue("resource", RESOURCE); + atts.setValue("optional", "true"); + + action.begin(ic, "include", atts); + + assertTrue(action.includedUrls.isEmpty()); + assertNoStatus(); + } + + @Test + public void reachableUrlIsIncluded() throws Exception { + URL url = tmp.newFile("included.xml").toURI().toURL(); + atts.setValue("url", url.toString()); + + action.begin(ic, "include", atts); + + assertEquals(Collections.singletonList(url), action.includedUrls); + assertEquals(url.toString(), action.getAttributeInUse()); + assertNoStatus(); + } + + @Test + public void malformedUrlIsAnError() throws Exception { + atts.setValue("url", "htp://logback.qos.ch"); + + action.begin(ic, "include", atts); + + assertTrue(action.includedUrls.isEmpty()); + Status status = assertStatus(Status.ERROR, "URL [htp://logback.qos.ch] is not well formed."); + assertTrue(status.getThrowable() instanceof MalformedURLException); + } + + @Test + public void unopenableUrlIsAWarning() throws Exception { + String url = new File(tmp.getRoot(), "missing.xml").toURI().toURL().toString(); + atts.setValue("url", url); + + action.begin(ic, "include", atts); + + assertTrue(action.includedUrls.isEmpty()); + Status status = assertStatus(Status.WARN, "URL [" + url + "] cannot be opened."); + assertTrue(status.getThrowable() instanceof FileNotFoundException); + } + + @Test + public void resourceIsIncluded() throws Exception { + URL url = Loader.getResourceBySelfClassLoader(RESOURCE); + assertNotNull(url); + atts.setValue("resource", RESOURCE); + + action.begin(ic, "include", atts); + + assertEquals(Collections.singletonList(url), action.includedUrls); + assertEquals(RESOURCE, action.getAttributeInUse()); + assertNoStatus(); + } + + @Test + public void missingResourceIsAnError() throws Exception { + atts.setValue("resource", "no/such/resource.xml"); + + action.begin(ic, "include", atts); + + assertTrue(action.includedUrls.isEmpty()); + Status status = assertStatus(Status.ERROR, "Could not find resource corresponding to [no/such/resource.xml]"); + assertNull(status.getThrowable()); + } + + @Test + public void failureToProcessTheIncludedDocumentIsAnError() throws Exception { + File file = tmp.newFile("included.xml"); + action.failure = new JoranException("bad document"); + atts.setValue("file", file.getPath()); + + action.begin(ic, "include", atts); + + assertEquals(1, action.includedUrls.size()); + Status status = assertStatus(Status.ERROR, "Error while parsing " + file.getPath()); + assertSame(action.failure, status.getThrowable()); + } + + @Test + public void failureToProcessAnOptionalDocumentIsSilent() throws Exception { + File file = tmp.newFile("included.xml"); + action.failure = new JoranException("bad document"); + atts.setValue("file", file.getPath()); + atts.setValue("optional", "true"); + + action.begin(ic, "include", atts); + + assertEquals(1, action.includedUrls.size()); + assertNoStatus(); + } + + @Test + public void optionalFlagIsReadAgainOnEachBegin() throws Exception { + atts.setValue("optional", "true"); + action.begin(ic, "include", atts); + assertTrue(action.isOptional()); + + atts.setValue("optional", "false"); + action.begin(ic, "include", atts); + + assertFalse(action.isOptional()); + assertStatus(Status.ERROR, NO_SOURCE_MESSAGE); + } + + @Test + public void sourceAttributeVanishingAfterTheCheckIsAnIllegalState() { + Attributes changing = mock(Attributes.class); + when(changing.getValue("resource")).thenReturn(RESOURCE, (String) null); + + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> action.begin(ic, "include", changing)); + + assertEquals("A URL stream should have been returned", e.getMessage()); + assertTrue(action.includedUrls.isEmpty()); + } + + @Test + public void notFoundErrorsAreReportedAsWarnings() { + FileNotFoundException notFound = new FileNotFoundException("f"); + UnknownHostException unknownHost = new UnknownHostException("h"); + + action.handleError("not found", notFound); + action.handleError("unknown host", unknownHost); + + assertSame(notFound, assertStatus(Status.WARN, "not found").getThrowable()); + assertSame(unknownHost, assertStatus(Status.WARN, "unknown host").getThrowable()); + } + + @Test + public void otherErrorsAreReportedAsErrors() { + IOException ioException = new IOException("io"); + + action.handleError("io failure", ioException); + action.handleError("no exception", null); + + assertSame(ioException, assertStatus(Status.ERROR, "io failure").getThrowable()); + assertNull(assertStatus(Status.ERROR, "no exception").getThrowable()); + } + + @Test + public void closeClosesTheStream() { + CloseTrackingInputStream in = new CloseTrackingInputStream(null); + + action.close(in); + + assertTrue(in.closed); + } + + @Test + public void closeIgnoresNullAndFailingStreams() { + CloseTrackingInputStream failing = new CloseTrackingInputStream(new IOException("cannot close")); + + action.close(null); + action.close(failing); + + assertTrue(failing.closed); + assertNoStatus(); + } + + @Test + public void endChangesNothing() throws ActionException { + ic.pushObject("top"); + + action.end(ic, "include"); + + assertEquals("top", ic.peekObject()); + assertTrue(action.includedUrls.isEmpty()); + assertNoStatus(); + } + + private Status assertStatus(int level, String message) { + List statuses = context.getStatusManager().getCopyOfStatusList(); + for (Status s : statuses) { + if (s.getLevel() == level && message.equals(s.getMessage())) { + return s; + } + } + throw new AssertionError("no status of level " + level + " with message [" + message + "] in " + statuses); + } + + private void assertNoStatus() { + assertEquals(Collections.emptyList(), context.getStatusManager().getCopyOfStatusList()); + } + + static class RecordingIncludeAction extends AbstractIncludeAction { + final List includedUrls = new ArrayList(); + JoranException failure; + + @Override + protected void processInclude(InterpretationContext ic, URL url) throws JoranException { + includedUrls.add(url); + if (failure != null) { + throw failure; + } + } + } + + static class CloseTrackingInputStream extends ByteArrayInputStream { + final IOException closeFailure; + boolean closed; + + CloseTrackingInputStream(IOException closeFailure) { + super(new byte[0]); + this.closeFailure = closeFailure; + } + + @Override + public void close() throws IOException { + closed = true; + if (closeFailure != null) { + throw closeFailure; + } + } + } +} From aad969171ec78f766da34238af903f24f4657f00 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:31:36 +0000 Subject: [PATCH 62/79] test: cover Action, ActionConst and the evaluator, appender, appender-ref and conversion-rule actions Plain JVM tests that bring these core.joran.action classes to full line and branch coverage on their own: - AbstractEventEvaluatorAction: the evaluator is created from the class attribute or the default class, named, bound to the context and pushed; missing class/name and uninstantiable classes are errors that disable end(); end() starts the evaluator and registers it in the evaluator map, warns when another object is on top of the stack, and reports a missing or read-only map. - AppenderAction: appenders are created, named (with substitution), bagged and pushed; missing names are warned about, ConsoleAppender is flagged as deprecated, and missing classes, uninstantiable classes and a missing appender bag are errors (the latter two abort with ActionException); end() starts and pops the appender or warns when it is not on top. - AppenderRefAction: the referenced appender is attached to the AppenderAttachable on top; a wrong top object, a missing ref and an unknown appender are errors. - ConversionRuleAction: rules create or extend the rule registry; missing attributes and a registry that cannot be updated are errors. - Action: line/column come from the interpreter's locator (-1 without one); body() is a no-op. ActionConst: the shared constants. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../AbstractEventEvaluatorActionTest.java | 253 ++++++++++++++++++ .../core/joran/action/ActionConstTest.java | 55 ++++ .../logback/core/joran/action/ActionTest.java | 96 +++++++ .../core/joran/action/AppenderActionTest.java | 217 +++++++++++++++ .../joran/action/AppenderRefActionTest.java | 155 +++++++++++ .../action/ConversionRuleActionTest.java | 164 ++++++++++++ 6 files changed, 940 insertions(+) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/action/AbstractEventEvaluatorActionTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/action/ActionConstTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/action/ActionTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/action/AppenderActionTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/action/AppenderRefActionTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/action/ConversionRuleActionTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/AbstractEventEvaluatorActionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/AbstractEventEvaluatorActionTest.java new file mode 100644 index 000000000..83f861950 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/AbstractEventEvaluatorActionTest.java @@ -0,0 +1,253 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.boolex.EvaluationException; +import ch.qos.logback.core.boolex.EventEvaluator; +import ch.qos.logback.core.boolex.EventEvaluatorBase; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.util.DynamicClassLoadingException; + +/** + * Tests {@link AbstractEventEvaluatorAction}. + */ +public class AbstractEventEvaluatorActionTest { + + static final String EVALUATOR_CLASS = TestEvaluator.class.getName(); + + ContextBase context = new ContextBase(); + InterpretationContext ic = new InterpretationContext(context, null); + TestEvaluatorAction action = new TestEvaluatorAction(); + DummyAttributes atts = new DummyAttributes(); + Map> evaluatorMap = new HashMap>(); + + @Before + public void setUp() { + action.setContext(context); + context.putObject(CoreConstants.EVALUATOR_MAP, evaluatorMap); + } + + @Test + public void beginPushesANamedEvaluatorOfTheGivenClass() { + atts.setValue(Action.CLASS_ATTRIBUTE, EVALUATOR_CLASS); + atts.setValue(Action.NAME_ATTRIBUTE, "myEval"); + + action.begin(ic, "evaluator", atts); + + assertFalse(action.inError); + TestEvaluator evaluator = (TestEvaluator) ic.peekObject(); + assertSame(action.evaluator, evaluator); + assertEquals("myEval", evaluator.getName()); + assertSame(context, evaluator.getContext()); + assertFalse(evaluator.isStarted()); + assertStatus(Status.INFO, "Adding evaluator named [myEval] to the object stack"); + assertNoStatus("Assuming default evaluator class"); + } + + @Test + public void beginFallsBackToTheDefaultClassWhenNoneIsGiven() { + action.defaultClassName = EVALUATOR_CLASS; + atts.setValue(Action.NAME_ATTRIBUTE, "myEval"); + + action.begin(ic, "evaluator", atts); + + assertFalse(action.inError); + assertTrue(ic.peekObject() instanceof TestEvaluator); + assertStatus(Status.INFO, "Assuming default evaluator class [" + EVALUATOR_CLASS + "]"); + } + + @Test + public void missingClassWithoutDefaultIsAnError() { + action.defaultClassName = null; + atts.setValue(Action.NAME_ATTRIBUTE, "myEval"); + + action.begin(ic, "evaluator", atts); + + assertTrue(action.inError); + assertNull(action.evaluator); + assertTrue(ic.isEmpty()); + assertStatus(Status.ERROR, "Mandatory \"class\" attribute not set for "); + } + + @Test + public void missingNameIsAnError() { + atts.setValue(Action.CLASS_ATTRIBUTE, EVALUATOR_CLASS); + + action.begin(ic, "evaluator", atts); + + assertTrue(action.inError); + assertNull(action.evaluator); + assertTrue(ic.isEmpty()); + assertStatus(Status.ERROR, "Mandatory \"name\" attribute not set for "); + } + + @Test + public void uninstantiableClassIsAnError() { + atts.setValue(Action.CLASS_ATTRIBUTE, "no.such.Evaluator"); + atts.setValue(Action.NAME_ATTRIBUTE, "myEval"); + + action.begin(ic, "evaluator", atts); + + assertTrue(action.inError); + assertTrue(ic.isEmpty()); + Status status = assertStatus(Status.ERROR, "Could not create evaluator of type no.such.Evaluator]."); + assertTrue(status.getThrowable() instanceof DynamicClassLoadingException); + } + + @Test + public void endAfterAFailedBeginDoesNothing() { + action.begin(ic, "evaluator", atts); // neither class nor name + int statusCount = context.getStatusManager().getCount(); + ic.pushObject("unrelated"); + + action.end(ic, "evaluator"); + + assertEquals(statusCount, context.getStatusManager().getCount()); + assertEquals("unrelated", ic.peekObject()); + assertTrue(evaluatorMap.isEmpty()); + } + + @Test + public void endStartsAndRegistersTheEvaluator() { + TestEvaluator evaluator = beginWithValidEvaluator(); + + action.end(ic, "evaluator"); + + assertTrue(evaluator.isStarted()); + assertTrue(ic.isEmpty()); + assertSame(evaluator, evaluatorMap.get("myEval")); + assertStatus(Status.INFO, "Starting evaluator named [myEval]"); + } + + @Test + public void endLeavesTheStackAloneWhenTheEvaluatorIsNotOnTop() { + TestEvaluator evaluator = beginWithValidEvaluator(); + ic.pushObject("intruder"); + + action.end(ic, "evaluator"); + + assertTrue(evaluator.isStarted()); + assertEquals("intruder", ic.popObject()); + assertSame(evaluator, ic.popObject()); + assertTrue(evaluatorMap.isEmpty()); + assertStatus(Status.WARN, "The object on the top the of the stack is not the evaluator pushed earlier."); + } + + @Test + public void endWithoutEvaluatorMapIsAnError() { + context.removeObject(CoreConstants.EVALUATOR_MAP); + TestEvaluator evaluator = beginWithValidEvaluator(); + + action.end(ic, "evaluator"); + + assertTrue(evaluator.isStarted()); + assertTrue(ic.isEmpty()); + assertStatus(Status.ERROR, "Could not find EvaluatorMap"); + } + + @Test + public void failureToRegisterTheEvaluatorIsAnError() { + context.putObject(CoreConstants.EVALUATOR_MAP, Collections.>emptyMap()); + TestEvaluator evaluator = beginWithValidEvaluator(); + + action.end(ic, "evaluator"); + + assertTrue(ic.isEmpty()); + Status status = assertStatus(Status.ERROR, "Could not set evaluator named [" + evaluator + "]."); + assertTrue(status.getThrowable() instanceof UnsupportedOperationException); + } + + @Test + public void beginForgetsThePreviousError() { + action.begin(ic, "evaluator", atts); // neither class nor name + assertTrue(action.inError); + + TestEvaluator evaluator = beginWithValidEvaluator(); + action.end(ic, "evaluator"); + + assertTrue(evaluator.isStarted()); + assertSame(evaluator, evaluatorMap.get("myEval")); + } + + @Test + public void finishLeavesTheInterpretationContextUnchanged() { + TestEvaluator evaluator = beginWithValidEvaluator(); + int statusCount = context.getStatusManager().getCount(); + + action.finish(ic); + + assertSame(evaluator, ic.peekObject()); + assertFalse(evaluator.isStarted()); + assertEquals(statusCount, context.getStatusManager().getCount()); + } + + private TestEvaluator beginWithValidEvaluator() { + atts.setValue(Action.CLASS_ATTRIBUTE, EVALUATOR_CLASS); + atts.setValue(Action.NAME_ATTRIBUTE, "myEval"); + action.begin(ic, "evaluator", atts); + assertFalse(action.inError); + return (TestEvaluator) ic.peekObject(); + } + + private Status assertStatus(int level, String message) { + List statuses = context.getStatusManager().getCopyOfStatusList(); + for (Status s : statuses) { + if (s.getLevel() == level && message.equals(s.getMessage())) { + return s; + } + } + throw new AssertionError("no status of level " + level + " with message [" + message + "] in " + statuses); + } + + private void assertNoStatus(String messagePrefix) { + for (Status s : context.getStatusManager().getCopyOfStatusList()) { + assertFalse(s.getMessage(), s.getMessage().startsWith(messagePrefix)); + } + } + + static class TestEvaluatorAction extends AbstractEventEvaluatorAction { + String defaultClassName; + + @Override + protected String defaultClassName() { + return defaultClassName; + } + } + + public static class TestEvaluator extends EventEvaluatorBase { + @Override + public boolean evaluate(Object event) throws EvaluationException { + return true; + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/ActionConstTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/ActionConstTest.java new file mode 100644 index 000000000..2567b18d3 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/ActionConstTest.java @@ -0,0 +1,55 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.action; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +/** + * Tests {@link ActionConst}. + */ +public class ActionConstTest { + + @Test + public void oneStringParamDescribesASingleStringParameter() { + assertArrayEquals(new Class[] { String.class }, ActionConst.ONE_STRING_PARAM); + } + + @Test + public void attributeAndTagNamesMatchTheConfigurationSyntax() { + assertEquals("appender", ActionConst.APPENDER_TAG); + assertEquals("ref", ActionConst.REF_ATTRIBUTE); + assertEquals("additivity", ActionConst.ADDITIVITY_ATTRIBUTE); + assertEquals("level", ActionConst.LEVEL_ATTRIBUTE); + assertEquals("converterClass", ActionConst.CONVERTER_CLASS_ATTRIBUTE); + assertEquals("conversionWord", ActionConst.CONVERSION_WORD_ATTRIBUTE); + assertEquals("pattern", ActionConst.PATTERN_ATTRIBUTE); + assertEquals("value", ActionConst.VALUE_ATTR); + assertEquals("actionClass", ActionConst.ACTION_CLASS_ATTRIBUTE); + assertEquals("INHERITED", ActionConst.INHERITED); + assertEquals("NULL", ActionConst.NULL); + assertEquals("APPENDER_BAG", ActionConst.APPENDER_BAG); + } + + @Test + public void canBeExtendedToShareTheConstants() { + ActionConst constants = new ActionConst() { + }; + assertEquals(ActionConst.class, constants.getClass().getSuperclass()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/ActionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/ActionTest.java new file mode 100644 index 000000000..7f3c1305c --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/ActionTest.java @@ -0,0 +1,96 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Before; +import org.junit.Test; +import org.xml.sax.Attributes; +import org.xml.sax.helpers.LocatorImpl; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.joran.spi.ElementPath; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.joran.spi.Interpreter; +import ch.qos.logback.core.joran.spi.SimpleRuleStore; + +/** + * Tests the location helpers of {@link Action}. + */ +public class ActionTest { + + Context context = new ContextBase(); + Interpreter interpreter; + InterpretationContext ic; + LocatingAction action = new LocatingAction(); + + @Before + public void setUp() { + interpreter = new Interpreter(context, new SimpleRuleStore(context), new ElementPath()); + ic = interpreter.getInterpretationContext(); + action.setContext(context); + } + + @Test + public void lineAndColumnComeFromTheInterpreterLocator() { + LocatorImpl locator = new LocatorImpl(); + locator.setLineNumber(12); + locator.setColumnNumber(34); + interpreter.setDocumentLocator(locator); + + assertEquals(12, action.getLineNumber(ic)); + assertEquals(34, action.getColumnNumber(ic)); + assertEquals("line: 12, column: 34", action.getLineColStr(ic)); + } + + @Test + public void lineAndColumnAreMinusOneWithoutLocator() { + interpreter.setDocumentLocator(null); + + assertEquals(-1, action.getLineNumber(ic)); + assertEquals(-1, action.getColumnNumber(ic)); + assertEquals("line: -1, column: -1", action.getLineColStr(ic)); + } + + @Test + public void bodyIsIgnoredByDefault() throws Exception { + ic.pushObject("top"); + + action.body(ic, "some text"); + + assertEquals("top", ic.popObject()); + assertTrue(ic.isEmpty()); + assertEquals(0, context.getStatusManager().getCount()); + } + + @Test + public void toStringIsTheClassName() { + assertEquals(LocatingAction.class.getName(), action.toString()); + } + + static class LocatingAction extends Action { + @Override + public void begin(InterpretationContext ic, String name, Attributes attributes) { + } + + @Override + public void end(InterpretationContext ic, String name) { + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/AppenderActionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/AppenderActionTest.java new file mode 100644 index 000000000..e9d83522a --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/AppenderActionTest.java @@ -0,0 +1,217 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.HashMap; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; +import org.xml.sax.helpers.LocatorImpl; + +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.joran.spi.ActionException; +import ch.qos.logback.core.joran.spi.ElementPath; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.joran.spi.Interpreter; +import ch.qos.logback.core.joran.spi.SimpleRuleStore; +import ch.qos.logback.core.read.ListAppender; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.util.DynamicClassLoadingException; + +/** + * Tests {@link AppenderAction}. + */ +public class AppenderActionTest { + + static final String LIST_APPENDER_CLASS = ListAppender.class.getName(); + + Context context = new ContextBase(); + InterpretationContext ic; + HashMap> appenderBag = new HashMap>(); + AppenderAction action = new AppenderAction(); + DummyAttributes atts = new DummyAttributes(); + + @Before + public void setUp() { + Interpreter interpreter = new Interpreter(context, new SimpleRuleStore(context), new ElementPath()); + LocatorImpl locator = new LocatorImpl(); + locator.setLineNumber(7); + interpreter.setDocumentLocator(locator); + ic = interpreter.getInterpretationContext(); + ic.getObjectMap().put(ActionConst.APPENDER_BAG, appenderBag); + action.setContext(context); + } + + @Test + public void beginPushesAndBagsANamedAppenderOfTheGivenClass() throws ActionException { + context.putProperty("appenderName", "A1"); + atts.setValue(Action.CLASS_ATTRIBUTE, LIST_APPENDER_CLASS); + atts.setValue(Action.NAME_ATTRIBUTE, "${appenderName}"); + + action.begin(ic, "appender", atts); + + Appender appender = (Appender) ic.peekObject(); + assertTrue(appender instanceof ListAppender); + assertEquals("A1", appender.getName()); + assertSame(context, appender.getContext()); + assertFalse(appender.isStarted()); + assertSame(appender, appenderBag.get("A1")); + assertStatus(Status.INFO, "About to instantiate appender of type [" + LIST_APPENDER_CLASS + "]"); + assertStatus(Status.INFO, "Naming appender as [A1]"); + assertNoStatusAtOrAbove(Status.WARN); + } + + @Test + public void appenderWithoutNameIsWarnedAboutAndBaggedUnderNull() throws ActionException { + atts.setValue(Action.CLASS_ATTRIBUTE, LIST_APPENDER_CLASS); + + action.begin(ic, "appender", atts); + + Appender appender = (Appender) ic.peekObject(); + assertNull(appender.getName()); + assertSame(appender, appenderBag.get(null)); + assertStatus(Status.WARN, "No appender name given for appender of type " + LIST_APPENDER_CLASS + "]."); + } + + @Test + public void consoleAppenderIsWarnedAboutAsDeprecated() throws ActionException { + atts.setValue(Action.CLASS_ATTRIBUTE, "ch.qos.logback.core.ConsoleAppender"); + atts.setValue(Action.NAME_ATTRIBUTE, "console"); + + action.begin(ic, "appender", atts); + + assertEquals("ch.qos.logback.core.ConsoleAppender", ic.peekObject().getClass().getName()); + assertStatus(Status.WARN, "ConsoleAppender is deprecated for LogcatAppender"); + } + + @Test + public void missingClassIsAnErrorThatDisablesEnd() throws ActionException { + atts.setValue(Action.NAME_ATTRIBUTE, "A1"); + + action.begin(ic, "appender", atts); + + assertTrue(ic.isEmpty()); + assertTrue(appenderBag.isEmpty()); + assertStatus(Status.ERROR, "Missing class name for appender. Near [appender] line 7"); + + int statusCount = context.getStatusManager().getCount(); + ic.pushObject("unrelated"); + action.end(ic, "appender"); + assertEquals("unrelated", ic.peekObject()); + assertEquals(statusCount, context.getStatusManager().getCount()); + } + + @Test + public void uninstantiableClassIsAnErrorThatAbortsTheElement() { + atts.setValue(Action.CLASS_ATTRIBUTE, "no.such.Appender"); + atts.setValue(Action.NAME_ATTRIBUTE, "A1"); + + ActionException e = assertThrows(ActionException.class, () -> action.begin(ic, "appender", atts)); + + assertTrue(e.getCause() instanceof DynamicClassLoadingException); + assertTrue(ic.isEmpty()); + assertTrue(appenderBag.isEmpty()); + Status status = assertStatus(Status.ERROR, "Could not create an Appender of type [no.such.Appender]."); + assertSame(e.getCause(), status.getThrowable()); + + // the failed element leaves nothing for end() to do + int statusCount = context.getStatusManager().getCount(); + action.end(ic, "appender"); + assertTrue(ic.isEmpty()); + assertEquals(statusCount, context.getStatusManager().getCount()); + } + + @Test + public void missingAppenderBagIsAnError() { + ic.getObjectMap().remove(ActionConst.APPENDER_BAG); + atts.setValue(Action.CLASS_ATTRIBUTE, LIST_APPENDER_CLASS); + atts.setValue(Action.NAME_ATTRIBUTE, "A1"); + + ActionException e = assertThrows(ActionException.class, () -> action.begin(ic, "appender", atts)); + + assertTrue(e.getCause() instanceof NullPointerException); + assertTrue(ic.isEmpty()); + assertStatus(Status.ERROR, "Could not create an Appender of type [" + LIST_APPENDER_CLASS + "]."); + } + + @Test + public void endStartsAndPopsTheAppender() throws ActionException { + Appender appender = beginWithListAppender(); + + action.end(ic, "appender"); + + assertTrue(appender.isStarted()); + assertTrue(ic.isEmpty()); + assertNoStatusAtOrAbove(Status.WARN); + } + + @Test + public void endLeavesTheStackAloneWhenTheAppenderIsNotOnTop() throws ActionException { + Appender appender = beginWithListAppender(); + ic.pushObject("intruder"); + + action.end(ic, "appender"); + + assertTrue(appender.isStarted()); + assertEquals("intruder", ic.popObject()); + assertSame(appender, ic.popObject()); + assertStatus(Status.WARN, "The object at the of the stack is not the appender named [A1] pushed earlier."); + } + + @Test + public void beginForgetsThePreviousError() throws ActionException { + action.begin(ic, "appender", atts); // no class + + Appender appender = beginWithListAppender(); + action.end(ic, "appender"); + + assertTrue(appender.isStarted()); + assertTrue(ic.isEmpty()); + } + + private Appender beginWithListAppender() throws ActionException { + atts.setValue(Action.CLASS_ATTRIBUTE, LIST_APPENDER_CLASS); + atts.setValue(Action.NAME_ATTRIBUTE, "A1"); + action.begin(ic, "appender", atts); + return (Appender) ic.peekObject(); + } + + private Status assertStatus(int level, String message) { + List statuses = context.getStatusManager().getCopyOfStatusList(); + for (Status s : statuses) { + if (s.getLevel() == level && message.equals(s.getMessage())) { + return s; + } + } + throw new AssertionError("no status of level " + level + " with message [" + message + "] in " + statuses); + } + + private void assertNoStatusAtOrAbove(int level) { + for (Status s : context.getStatusManager().getCopyOfStatusList()) { + assertTrue(s.toString(), s.getLevel() < level); + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/AppenderRefActionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/AppenderRefActionTest.java new file mode 100644 index 000000000..bb22dda3f --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/AppenderRefActionTest.java @@ -0,0 +1,155 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.HashMap; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; +import org.xml.sax.helpers.LocatorImpl; + +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.joran.spi.ElementPath; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.joran.spi.Interpreter; +import ch.qos.logback.core.joran.spi.SimpleRuleStore; +import ch.qos.logback.core.read.ListAppender; +import ch.qos.logback.core.spi.AppenderAttachableImpl; +import ch.qos.logback.core.status.Status; + +/** + * Tests {@link AppenderRefAction}. + */ +public class AppenderRefActionTest { + + Context context = new ContextBase(); + InterpretationContext ic; + HashMap> appenderBag = new HashMap>(); + AppenderAttachableImpl attachable = new AppenderAttachableImpl(); + ListAppender listAppender = new ListAppender(); + AppenderRefAction action = new AppenderRefAction(); + DummyAttributes atts = new DummyAttributes(); + + @Before + public void setUp() { + Interpreter interpreter = new Interpreter(context, new SimpleRuleStore(context), new ElementPath()); + LocatorImpl locator = new LocatorImpl(); + locator.setLineNumber(9); + interpreter.setDocumentLocator(locator); + ic = interpreter.getInterpretationContext(); + ic.getObjectMap().put(ActionConst.APPENDER_BAG, appenderBag); + action.setContext(context); + + listAppender.setName("A1"); + appenderBag.put("A1", listAppender); + } + + @Test + public void referencedAppenderIsAttachedToTheObjectOnTop() { + context.putProperty("ref", "A1"); + ic.pushObject(attachable); + atts.setValue(ActionConst.REF_ATTRIBUTE, "${ref}"); + + action.begin(ic, "appender-ref", atts); + + assertFalse(action.inError); + assertTrue(attachable.isAttached(listAppender)); + assertSame(attachable, ic.peekObject()); + assertStatus(Status.INFO, "Attaching appender named [A1] to " + attachable); + } + + @Test + public void objectOnTopThatCannotTakeAppendersIsAnError() { + ic.pushObject("not an AppenderAttachable"); + atts.setValue(ActionConst.REF_ATTRIBUTE, "A1"); + + action.begin(ic, "appender-ref", atts); + + assertTrue(action.inError); + assertStatus(Status.ERROR, + "Could not find an AppenderAttachable at the top of execution stack. Near [appender-ref] line 9"); + assertNull(attachable.getAppender("A1")); + } + + @Test + public void missingRefAttributeIsAnError() { + ic.pushObject(attachable); + + action.begin(ic, "appender-ref", atts); + + assertTrue(action.inError); + assertStatus(Status.ERROR, "Missing appender ref attribute in tag."); + assertFalse(attachable.iteratorForAppenders().hasNext()); + } + + @Test + public void unknownAppenderIsAnError() { + ic.pushObject(attachable); + atts.setValue(ActionConst.REF_ATTRIBUTE, "nope"); + + action.begin(ic, "appender-ref", atts); + + assertTrue(action.inError); + assertStatus(Status.ERROR, + "Could not find an appender named [nope]. Did you define it below instead of above in the configuration file?"); + assertStatus(Status.ERROR, "See " + CoreConstants.CODES_URL + "#appender_order for more details."); + assertFalse(attachable.iteratorForAppenders().hasNext()); + } + + @Test + public void beginForgetsThePreviousError() { + ic.pushObject(attachable); + action.begin(ic, "appender-ref", atts); // no ref + assertTrue(action.inError); + + atts.setValue(ActionConst.REF_ATTRIBUTE, "A1"); + action.begin(ic, "appender-ref", atts); + + assertFalse(action.inError); + assertTrue(attachable.isAttached(listAppender)); + } + + @Test + public void endChangesNothing() { + ic.pushObject(attachable); + int statusCount = context.getStatusManager().getCount(); + + action.end(ic, "appender-ref"); + + assertSame(attachable, ic.peekObject()); + assertEquals(statusCount, context.getStatusManager().getCount()); + } + + private Status assertStatus(int level, String message) { + List statuses = context.getStatusManager().getCopyOfStatusList(); + for (Status s : statuses) { + if (s.getLevel() == level && message.equals(s.getMessage())) { + return s; + } + } + throw new AssertionError("no status of level " + level + " with message [" + message + "] in " + statuses); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/ConversionRuleActionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/ConversionRuleActionTest.java new file mode 100644 index 000000000..05bc65ef3 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/ConversionRuleActionTest.java @@ -0,0 +1,164 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.status.Status; + +/** + * Tests {@link ConversionRuleAction}. + */ +public class ConversionRuleActionTest { + + static final String CONVERTER_CLASS = "com.example.MyConverter"; + + Context context = new ContextBase(); + InterpretationContext ic = new InterpretationContext(context, null); + ConversionRuleAction action = new ConversionRuleAction(); + DummyAttributes atts = new DummyAttributes(); + + @Before + public void setUp() { + action.setContext(context); + } + + @Test + public void firstRuleCreatesTheRuleRegistry() { + assertNull(context.getObject(CoreConstants.PATTERN_RULE_REGISTRY)); + atts.setValue(ActionConst.CONVERSION_WORD_ATTRIBUTE, "my"); + atts.setValue(ActionConst.CONVERTER_CLASS_ATTRIBUTE, CONVERTER_CLASS); + + action.begin(ic, "conversionRule", atts); + + assertFalse(action.inError); + assertEquals(Collections.singletonMap("my", CONVERTER_CLASS), ruleRegistry()); + assertStatus(Status.INFO, "registering conversion word my with class [" + CONVERTER_CLASS + "]"); + } + + @Test + public void laterRulesAreAddedToTheExistingRegistry() { + Map registry = new HashMap(); + registry.put("other", "com.example.OtherConverter"); + context.putObject(CoreConstants.PATTERN_RULE_REGISTRY, registry); + atts.setValue(ActionConst.CONVERSION_WORD_ATTRIBUTE, "my"); + atts.setValue(ActionConst.CONVERTER_CLASS_ATTRIBUTE, CONVERTER_CLASS); + + action.begin(ic, "conversionRule", atts); + + assertFalse(action.inError); + assertSame(registry, ruleRegistry()); + assertEquals(2, registry.size()); + assertEquals(CONVERTER_CLASS, registry.get("my")); + assertEquals("com.example.OtherConverter", registry.get("other")); + } + + @Test + public void missingConversionWordIsAnError() { + atts.setValue(ActionConst.CONVERTER_CLASS_ATTRIBUTE, CONVERTER_CLASS); + + action.begin(ic, "conversionRule", atts); + + assertTrue(action.inError); + assertNull(ruleRegistry()); + assertStatus(Status.ERROR, "No 'conversionWord' attribute in "); + } + + @Test + public void missingConverterClassIsAnError() { + atts.setValue(ActionConst.CONVERSION_WORD_ATTRIBUTE, "my"); + + action.begin(ic, "conversionRule", atts); + + assertTrue(action.inError); + assertNull(ruleRegistry()); + assertStatus(Status.ERROR, "No 'converterClass' attribute in "); + } + + @Test + public void failureToRegisterTheRuleIsAnError() { + Map readOnly = Collections.emptyMap(); + context.putObject(CoreConstants.PATTERN_RULE_REGISTRY, readOnly); + atts.setValue(ActionConst.CONVERSION_WORD_ATTRIBUTE, "my"); + atts.setValue(ActionConst.CONVERTER_CLASS_ATTRIBUTE, CONVERTER_CLASS); + + action.begin(ic, "conversionRule", atts); + + assertTrue(action.inError); + assertTrue(readOnly.isEmpty()); + assertStatus(Status.ERROR, "Could not add conversion rule to PatternLayout."); + } + + @Test + public void beginForgetsThePreviousError() { + action.begin(ic, "conversionRule", atts); // no attributes at all + assertTrue(action.inError); + + atts.setValue(ActionConst.CONVERSION_WORD_ATTRIBUTE, "my"); + atts.setValue(ActionConst.CONVERTER_CLASS_ATTRIBUTE, CONVERTER_CLASS); + action.begin(ic, "conversionRule", atts); + + assertFalse(action.inError); + assertEquals(CONVERTER_CLASS, ruleRegistry().get("my")); + } + + @Test + public void endAndFinishChangeNothing() { + atts.setValue(ActionConst.CONVERSION_WORD_ATTRIBUTE, "my"); + atts.setValue(ActionConst.CONVERTER_CLASS_ATTRIBUTE, CONVERTER_CLASS); + action.begin(ic, "conversionRule", atts); + int statusCount = context.getStatusManager().getCount(); + + action.end(ic, "conversionRule"); + action.finish(ic); + + assertFalse(action.inError); + assertEquals(Collections.singletonMap("my", CONVERTER_CLASS), ruleRegistry()); + assertTrue(ic.isEmpty()); + assertEquals(statusCount, context.getStatusManager().getCount()); + } + + @SuppressWarnings("unchecked") + private Map ruleRegistry() { + return (Map) context.getObject(CoreConstants.PATTERN_RULE_REGISTRY); + } + + private Status assertStatus(int level, String message) { + List statuses = context.getStatusManager().getCopyOfStatusList(); + for (Status s : statuses) { + if (s.getLevel() == level && message.equals(s.getMessage())) { + return s; + } + } + throw new AssertionError("no status of level " + level + " with message [" + message + "] in " + statuses); + } +} From 30977466eeab0e2663f8d3b33129c06baad24f78 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:26:28 +0000 Subject: [PATCH 63/79] test: pin attribute resets, substitution and empty attributes in joran actions Mutation checks showed some covered lines were not pinned by any assertion: the reset of attributeInUse/evaluator/appender at the start of begin(), variable substitution of the url and resource include attributes, and isEmpty() (as opposed to null) checks on attributes. Add tests that fail under each of those mutations. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../AbstractEventEvaluatorActionTest.java | 26 +++++++++ .../action/AbstractIncludeActionTest.java | 53 +++++++++++++++++++ .../core/joran/action/AppenderActionTest.java | 14 +++++ .../joran/action/AppenderRefActionTest.java | 12 +++++ .../action/ConversionRuleActionTest.java | 24 +++++++++ 5 files changed, 129 insertions(+) diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/AbstractEventEvaluatorActionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/AbstractEventEvaluatorActionTest.java index 83f861950..78fd6626d 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/action/AbstractEventEvaluatorActionTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/AbstractEventEvaluatorActionTest.java @@ -86,6 +86,19 @@ public void beginFallsBackToTheDefaultClassWhenNoneIsGiven() { assertStatus(Status.INFO, "Assuming default evaluator class [" + EVALUATOR_CLASS + "]"); } + @Test + public void emptyClassAttributeFallsBackToTheDefaultClass() { + action.defaultClassName = EVALUATOR_CLASS; + atts.setValue(Action.CLASS_ATTRIBUTE, ""); + atts.setValue(Action.NAME_ATTRIBUTE, "myEval"); + + action.begin(ic, "evaluator", atts); + + assertFalse(action.inError); + assertTrue(ic.peekObject() instanceof TestEvaluator); + assertStatus(Status.INFO, "Assuming default evaluator class [" + EVALUATOR_CLASS + "]"); + } + @Test public void missingClassWithoutDefaultIsAnError() { action.defaultClassName = null; @@ -111,6 +124,19 @@ public void missingNameIsAnError() { assertStatus(Status.ERROR, "Mandatory \"name\" attribute not set for "); } + @Test + public void failedBeginForgetsThePreviousEvaluator() { + beginWithValidEvaluator(); + assertTrue(action.evaluator instanceof TestEvaluator); + + atts.setValue(Action.NAME_ATTRIBUTE, ""); + action.begin(ic, "evaluator", atts); + + assertTrue(action.inError); + assertNull(action.evaluator); + assertStatus(Status.ERROR, "Mandatory \"name\" attribute not set for "); + } + @Test public void uninstantiableClassIsAnError() { atts.setValue(Action.CLASS_ATTRIBUTE, "no.such.Evaluator"); diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/AbstractIncludeActionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/AbstractIncludeActionTest.java index 9ecaad070..571223142 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/action/AbstractIncludeActionTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/AbstractIncludeActionTest.java @@ -195,6 +195,19 @@ public void reachableUrlIsIncluded() throws Exception { assertNoStatus(); } + @Test + public void urlAttributeIsSubstitutedBeforeUse() throws Exception { + URL url = tmp.newFile("included.xml").toURI().toURL(); + context.putProperty("includeUrl", url.toString()); + atts.setValue("url", "${includeUrl}"); + + action.begin(ic, "include", atts); + + assertEquals(url.toString(), action.getAttributeInUse()); + assertEquals(Collections.singletonList(url), action.includedUrls); + assertNoStatus(); + } + @Test public void malformedUrlIsAnError() throws Exception { atts.setValue("url", "htp://logback.qos.ch"); @@ -231,6 +244,31 @@ public void resourceIsIncluded() throws Exception { assertNoStatus(); } + @Test + public void resourceAttributeIsSubstitutedBeforeUse() throws Exception { + context.putProperty("includeResource", RESOURCE); + atts.setValue("resource", "${includeResource}"); + + action.begin(ic, "include", atts); + + assertEquals(RESOURCE, action.getAttributeInUse()); + assertEquals(Collections.singletonList(Loader.getResourceBySelfClassLoader(RESOURCE)), action.includedUrls); + assertNoStatus(); + } + + @Test + public void emptySourceAttributesCountAsUnset() throws Exception { + atts.setValue("file", ""); + atts.setValue("url", ""); + atts.setValue("resource", RESOURCE); + + action.begin(ic, "include", atts); + + assertEquals(RESOURCE, action.getAttributeInUse()); + assertEquals(Collections.singletonList(Loader.getResourceBySelfClassLoader(RESOURCE)), action.includedUrls); + assertNoStatus(); + } + @Test public void missingResourceIsAnError() throws Exception { atts.setValue("resource", "no/such/resource.xml"); @@ -281,6 +319,21 @@ public void optionalFlagIsReadAgainOnEachBegin() throws Exception { assertStatus(Status.ERROR, NO_SOURCE_MESSAGE); } + @Test + public void attributeInUseIsForgottenOnEachBegin() throws Exception { + File file = tmp.newFile("included.xml"); + atts.setValue("file", file.getPath()); + action.begin(ic, "include", atts); + assertEquals(file.getPath(), action.getAttributeInUse()); + + atts.setValue("file", null); + action.begin(ic, "include", atts); + + assertNull(action.getAttributeInUse()); + assertEquals(1, action.includedUrls.size()); + assertStatus(Status.ERROR, NO_SOURCE_MESSAGE); + } + @Test public void sourceAttributeVanishingAfterTheCheckIsAnIllegalState() { Attributes changing = mock(Attributes.class); diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/AppenderActionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/AppenderActionTest.java index e9d83522a..261526476 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/action/AppenderActionTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/AppenderActionTest.java @@ -124,6 +124,20 @@ public void missingClassIsAnErrorThatDisablesEnd() throws ActionException { assertEquals(statusCount, context.getStatusManager().getCount()); } + @Test + public void failedBeginForgetsThePreviousAppender() throws ActionException { + Appender appender = beginWithListAppender(); + assertSame(appender, action.appender); + + atts.setValue(Action.CLASS_ATTRIBUTE, ""); + action.begin(ic, "appender", atts); + + assertNull(action.appender); + assertStatus(Status.ERROR, "Missing class name for appender. Near [appender] line 7"); + action.end(ic, "appender"); + assertFalse(appender.isStarted()); + } + @Test public void uninstantiableClassIsAnErrorThatAbortsTheElement() { atts.setValue(Action.CLASS_ATTRIBUTE, "no.such.Appender"); diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/AppenderRefActionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/AppenderRefActionTest.java index bb22dda3f..68957e965 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/action/AppenderRefActionTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/AppenderRefActionTest.java @@ -105,6 +105,18 @@ public void missingRefAttributeIsAnError() { assertFalse(attachable.iteratorForAppenders().hasNext()); } + @Test + public void emptyRefAttributeIsAnError() { + ic.pushObject(attachable); + atts.setValue(ActionConst.REF_ATTRIBUTE, ""); + + action.begin(ic, "appender-ref", atts); + + assertTrue(action.inError); + assertStatus(Status.ERROR, "Missing appender ref attribute in tag."); + assertFalse(attachable.iteratorForAppenders().hasNext()); + } + @Test public void unknownAppenderIsAnError() { ic.pushObject(attachable); diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/ConversionRuleActionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/ConversionRuleActionTest.java index 05bc65ef3..b9ac6c181 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/action/ConversionRuleActionTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/ConversionRuleActionTest.java @@ -104,6 +104,30 @@ public void missingConverterClassIsAnError() { assertStatus(Status.ERROR, "No 'converterClass' attribute in "); } + @Test + public void emptyConversionWordIsAnError() { + atts.setValue(ActionConst.CONVERSION_WORD_ATTRIBUTE, ""); + atts.setValue(ActionConst.CONVERTER_CLASS_ATTRIBUTE, CONVERTER_CLASS); + + action.begin(ic, "conversionRule", atts); + + assertTrue(action.inError); + assertNull(ruleRegistry()); + assertStatus(Status.ERROR, "No 'conversionWord' attribute in "); + } + + @Test + public void emptyConverterClassIsAnError() { + atts.setValue(ActionConst.CONVERSION_WORD_ATTRIBUTE, "my"); + atts.setValue(ActionConst.CONVERTER_CLASS_ATTRIBUTE, ""); + + action.begin(ic, "conversionRule", atts); + + assertTrue(action.inError); + assertNull(ruleRegistry()); + assertStatus(Status.ERROR, "No 'converterClass' attribute in "); + } + @Test public void failureToRegisterTheRuleIsAnError() { Map readOnly = Collections.emptyMap(); From c69494a16c421ccbe6b317c687ed8431533e1335 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:36:44 +0000 Subject: [PATCH 64/79] test: stop SocketAppenderMessageLossTest timing out on a loaded runner Its 3-second JUnit timeout covered Robolectric's sandbox startup as well as the socket round trips, and timed out once on CI (JDK 21, jdk8 variant) before a retry passed. The test uses no Android API: run it on the plain JVM, and give the timeout, which only guards against hangs, 10 seconds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../classic/net/SocketAppenderMessageLossTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/SocketAppenderMessageLossTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/SocketAppenderMessageLossTest.java index f263dc17a..a733be060 100755 --- a/logback-android/src/test/java/ch/qos/logback/classic/net/SocketAppenderMessageLossTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/SocketAppenderMessageLossTest.java @@ -22,8 +22,6 @@ import ch.qos.logback.core.testUtil.RandomUtil; import ch.qos.logback.core.util.Duration; import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; import java.util.ArrayList; import java.util.List; @@ -31,11 +29,13 @@ import static org.junit.Assert.assertTrue; -@RunWith(RobolectricTestRunner.class) public class SocketAppenderMessageLossTest { int runLen = 100; Duration reconnectionDelay = new Duration(1000); - static final int TIMEOUT = 3000; + // Only a guard against hangs: every run waits for all runLen events to + // arrive, which takes well under a second, but can take longer than a few + // seconds on a loaded CI runner. + static final int TIMEOUT = 10000; @Test(timeout = TIMEOUT) public void synchronousSocketAppender() throws Exception { From 218fef563f4b0cae2befb5275a4b8db446f8b776 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:45:48 +0000 Subject: [PATCH 65/79] refactor: route FixedWindowRollingPolicy's enum-switch default to ZIP javac compiles the switch over CompressionMode in rollover() with an implicit default branch. NONE, GZ and ZIP each have a case, so that branch can never run and JaCoCo reports it as a missed branch that no test can cover. Putting the default label on ZIP's body leaves one branch per mode. Behavior is the same for every CompressionMode. FixedWindowRollingPolicyTest covers rollover in all three modes, plus the start() validation paths, window clamping, deletion of the archive at maxIndex, and rollover with a negative maxIndex. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../rolling/FixedWindowRollingPolicy.java | 3 + .../rolling/FixedWindowRollingPolicyTest.java | 229 ++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/rolling/FixedWindowRollingPolicyTest.java diff --git a/logback-android/src/main/java/ch/qos/logback/core/rolling/FixedWindowRollingPolicy.java b/logback-android/src/main/java/ch/qos/logback/core/rolling/FixedWindowRollingPolicy.java index 035740967..77a932076 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/rolling/FixedWindowRollingPolicy.java +++ b/logback-android/src/main/java/ch/qos/logback/core/rolling/FixedWindowRollingPolicy.java @@ -160,6 +160,9 @@ public void rollover() throws RolloverFailure { compressor.compress(getActiveFileName(), fileNamePattern.convertInt(minIndex), null); break; case ZIP: + // javac gives every enum switch an implicit default; sharing ZIP's body + // keeps it from being a dead branch that no CompressionMode can reach + default: compressor.compress(getActiveFileName(), fileNamePattern.convertInt(minIndex), zipEntryFileNamePattern.convert(new Date())); break; } diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/FixedWindowRollingPolicyTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/FixedWindowRollingPolicyTest.java new file mode 100644 index 000000000..e59c67c21 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/FixedWindowRollingPolicyTest.java @@ -0,0 +1,229 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.rolling; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.regex.Pattern; +import java.util.zip.GZIPInputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; + +public class FixedWindowRollingPolicyTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + Context context = new ContextBase(); + StatusChecker checker = new StatusChecker(context); + RollingFileAppender rfa = new RollingFileAppender(); + FixedWindowRollingPolicy fwrp = new FixedWindowRollingPolicy(); + String activeFileName; + + @Before + public void setUp() { + activeFileName = path("active.log"); + rfa.setContext(context); + fwrp.setContext(context); + fwrp.setParent(rfa); + } + + private String path(String name) { + return new File(tmp.getRoot(), name).getAbsolutePath(); + } + + private static void write(String fileName, String content) throws IOException { + Files.write(new File(fileName).toPath(), content.getBytes("UTF-8")); + } + + private static String read(String fileName) throws IOException { + return new String(Files.readAllBytes(new File(fileName).toPath()), "UTF-8"); + } + + private void startWithWindow(String fileNamePattern, int minIndex, int maxIndex) { + rfa.setFile(activeFileName); + fwrp.setFileNamePattern(fileNamePattern); + fwrp.setMinIndex(minIndex); + fwrp.setMaxIndex(maxIndex); + fwrp.start(); + } + + private static byte[] readFully(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int n; + while ((n = in.read(buffer)) != -1) { + out.write(buffer, 0, n); + } + return out.toByteArray(); + } + + @Test + public void defaultWindowIsOneToSeven() { + assertEquals(1, fwrp.getMinIndex()); + assertEquals(7, fwrp.getMaxIndex()); + } + + @Test + public void startWithoutFileNamePatternIsRejected() { + rfa.setFile(activeFileName); + + IllegalStateException e = assertThrows(IllegalStateException.class, fwrp::start); + + assertEquals(FixedWindowRollingPolicy.FNP_NOT_SET + CoreConstants.SEE_FNP_NOT_SET, e.getMessage()); + checker.assertContainsMatch(Status.ERROR, Pattern.quote(FixedWindowRollingPolicy.FNP_NOT_SET)); + checker.assertContainsMatch(Status.ERROR, Pattern.quote(CoreConstants.SEE_FNP_NOT_SET)); + assertFalse(fwrp.isStarted()); + } + + @Test + public void startInPrudentModeIsRejected() { + rfa.setPrudent(true); + rfa.setFile(activeFileName); + fwrp.setFileNamePattern(path("archive.%i.log")); + + IllegalStateException e = assertThrows(IllegalStateException.class, fwrp::start); + + assertEquals("Prudent mode is not supported.", e.getMessage()); + checker.assertContainsMatch(Status.ERROR, "Prudent mode is not supported with FixedWindowRollingPolicy."); + checker.assertContainsMatch(Status.ERROR, Pattern.quote(FixedWindowRollingPolicy.PRUDENT_MODE_UNSUPPORTED)); + assertFalse(fwrp.isStarted()); + } + + @Test + public void maxIndexBelowMinIndexIsRaisedToMinIndex() { + startWithWindow(path("archive.%i.log"), 3, 2); + + assertTrue(fwrp.isStarted()); + assertEquals(3, fwrp.getMinIndex()); + assertEquals(3, fwrp.getMaxIndex()); + checker.assertContainsMatch(Status.WARN, "MaxIndex \\(2\\) cannot be smaller than MinIndex \\(3\\)."); + checker.assertContainsMatch(Status.WARN, "Setting maxIndex to equal minIndex."); + } + + @Test + public void oversizedWindowIsReducedToMaxWindowSize() { + startWithWindow(path("archive.%i.log"), 2, 30); + + assertTrue(fwrp.isStarted()); + assertEquals(2, fwrp.getMinIndex()); + assertEquals(22, fwrp.getMaxIndex()); + checker.assertContainsMatch(Status.WARN, "Large window sizes are not allowed."); + checker.assertContainsMatch(Status.WARN, "MaxIndex reduced to 22"); + } + + @Test + public void fileNamePatternWithoutIntegerTokenIsRejected() { + rfa.setFile(activeFileName); + fwrp.setFileNamePattern(path("archive.log")); + + IllegalStateException e = assertThrows(IllegalStateException.class, fwrp::start); + + assertEquals("FileNamePattern [" + path("archive.log") + "] does not contain a valid IntegerToken", + e.getMessage()); + assertFalse(fwrp.isStarted()); + } + + @Test + public void rolloverShiftsArchivesAndDropsTheOneAtMaxIndex() throws IOException { + startWithWindow(path("archive.%i.log"), 1, 3); + write(activeFileName, "active"); + write(path("archive.1.log"), "one"); + // archive.2.log is missing, so nothing is renamed onto archive.3.log + write(path("archive.3.log"), "three"); + + fwrp.rollover(); + + assertFalse(new File(activeFileName).exists()); + assertEquals("active", read(path("archive.1.log"))); + assertEquals("one", read(path("archive.2.log"))); + assertFalse(new File(path("archive.3.log")).exists()); + checker.assertContainsMatch(Status.INFO, + "Skipping roll-over for inexistent file " + Pattern.quote(path("archive.2.log"))); + } + + @Test + public void rolloverWithNegativeMaxIndexLeavesFilesAlone() throws IOException { + startWithWindow(path("archive.%i.log"), 1, 3); + fwrp.setMaxIndex(-1); + write(activeFileName, "active"); + + fwrp.rollover(); + + assertEquals("active", read(activeFileName)); + assertEquals(1, tmp.getRoot().list().length); + } + + @Test + public void rolloverWithoutCompressionRenamesActiveFile() throws IOException { + startWithWindow(path("archive.%i.log"), 1, 3); + write(activeFileName, "active"); + + fwrp.rollover(); + + assertFalse(new File(activeFileName).exists()); + assertEquals("active", read(path("archive.1.log"))); + } + + @Test + public void rolloverWithGzCompressionCompressesActiveFile() throws IOException { + startWithWindow(path("archive.%i.log.gz"), 1, 3); + write(activeFileName, "active"); + + fwrp.rollover(); + + assertFalse(new File(activeFileName).exists()); + try (InputStream in = new GZIPInputStream(new FileInputStream(path("archive.1.log.gz")))) { + assertArrayEquals("active".getBytes("UTF-8"), readFully(in)); + } + } + + @Test + public void rolloverWithZipCompressionCompressesActiveFile() throws IOException { + startWithWindow(path("archive.%i.log.zip"), 1, 3); + write(activeFileName, "active"); + + fwrp.rollover(); + + assertFalse(new File(activeFileName).exists()); + try (ZipInputStream in = new ZipInputStream(new FileInputStream(path("archive.1.log.zip")))) { + ZipEntry entry = in.getNextEntry(); + assertTrue(entry.getName(), entry.getName().matches("archive\\.\\d{4}-\\d{2}-\\d{2}_\\d{4}\\.log")); + assertArrayEquals("active".getBytes("UTF-8"), readFully(in)); + } + } +} From 9f385de72ba969033f320e824b2d64cdc519d342 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:45:57 +0000 Subject: [PATCH 66/79] test: cover core.recovery resilient streams and core.rolling policies Unit tests for the remaining gaps in ResilientOutputStreamBase, ResilientFileOutputStream, ResilientSyslogOutputStream, RollingFileAppender, RollingPolicyBase, RolloverFailure, SizeAndTimeBasedFNATP, SizeAndTimeBasedRollingPolicy, SizeBasedTriggeringPolicy, TimeBasedFileNamingAndTriggeringPolicyBase and TimeBasedRollingPolicy. The tests check: - recovery: single-byte and array write failures are reported. Writes are dropped while the stream backs off, and recovery runs once the back-off has elapsed. Recovery still happens when closing the broken stream fails, and a failed reopen is reported. A stream with no context prints one console warning, and a stream whose context has no status manager drops statuses. A failed fsync is reported. The syslog stream sends flushed bytes over loopback UDP and reopens to the same host and port. - RollingFileAppender: start is refused without a triggering or rolling policy. stop() works when either policy is missing. A failed rollover keeps appending to the active file instead of truncating it. A failure to reopen the active file is reported. Two patterns only collide when they are the same. - time and size policies: missing FileNamePattern, date token and maxFileSize are rejected. cleanHistoryOnStart removes expired archives. stop() reports compression and clean-up jobs that time out or fail. An unreadable active file does not set the initial period; this test needs a non-root user and is skipped (assumption) when run as root. Compressed archives advance the counter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../ResilientFileOutputStreamTest.java | 117 +++++++ .../ResilientOutputStreamBaseTest.java | 286 +++++++++++++++++ .../ResilientSyslogOutputStreamTest.java | 109 +++++++ .../core/rolling/RollingFileAppenderTest.java | 294 ++++++++++++++++++ .../core/rolling/RollingPolicyBaseTest.java | 50 +++ .../core/rolling/RolloverFailureTest.java | 45 +++ .../rolling/SizeAndTimeBasedFNATP_Test.java | 135 ++++++++ .../SizeAndTimeBasedRollingPolicyTest.java | 105 +++++++ .../SizeBasedTriggeringPolicyTest.java | 68 ++++ ...FileNamingAndTriggeringPolicyBaseTest.java | 66 ++++ .../rolling/TimeBasedRollingPolicyTest.java | 186 +++++++++++ 11 files changed, 1461 insertions(+) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/recovery/ResilientFileOutputStreamTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/recovery/ResilientOutputStreamBaseTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/recovery/ResilientSyslogOutputStreamTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/rolling/RollingPolicyBaseTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/rolling/RolloverFailureTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/rolling/SizeAndTimeBasedRollingPolicyTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/rolling/SizeBasedTriggeringPolicyTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/rolling/TimeBasedRollingPolicyTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/core/recovery/ResilientFileOutputStreamTest.java b/logback-android/src/test/java/ch/qos/logback/core/recovery/ResilientFileOutputStreamTest.java new file mode 100644 index 000000000..69da2ce03 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/recovery/ResilientFileOutputStreamTest.java @@ -0,0 +1,117 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.recovery; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.io.SyncFailedException; +import java.lang.reflect.Field; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.util.List; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.status.Status; + +public class ResilientFileOutputStreamTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + Context context = new ContextBase(); + File file; + ResilientFileOutputStream stream; + + @Before + public void setUp() throws IOException { + file = tmp.newFile("resilient.log"); + stream = new ResilientFileOutputStream(file, true, 8192); + stream.setContext(context); + } + + @After + public void tearDown() throws IOException { + if (stream.os != null) { + stream.os.close(); + } + } + + @Test + public void fileIsTheOneBeingWritten() { + assertSame(file, stream.getFile()); + } + + @Test + public void toStringIdentifiesTheInstance() { + assertEquals("c.q.l.c.recovery.ResilientFileOutputStream@" + System.identityHashCode(stream), + stream.toString()); + } + + @Test + public void channelIsNullWithoutUnderlyingStream() throws IOException { + FileChannel channel = stream.getChannel(); + assertNotNull(channel); + + stream.os = null; + + assertNull(stream.getChannel()); + channel.close(); + } + + @Test + public void failedSyncOnFlushIsReportedAsError() throws IOException { + stream.setSyncOnFlush(true); + // closing the channel also invalidates the file descriptor that flush() syncs + stream.getChannel().close(); + + stream.flush(); + + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(1, statuses.size()); + Status status = statuses.get(0); + assertEquals(Status.ERROR, status.getLevel()); + assertEquals("IO failure while writing to file [" + file + "]", status.getMessage()); + assertTrue(String.valueOf(status.getThrowable()), status.getThrowable() instanceof SyncFailedException); + } + + @Test + public void syncOnFlushIsSkippedWithoutFileStream() throws Exception { + stream.setSyncOnFlush(true); + stream.write(new byte[] {'o', 'k'}); + Field fos = ResilientFileOutputStream.class.getDeclaredField("fos"); + fos.setAccessible(true); + fos.set(stream, null); + + stream.flush(); + + assertEquals(0, context.getStatusManager().getCount()); + assertArrayEquals(new byte[] {'o', 'k'}, Files.readAllBytes(file.toPath())); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/recovery/ResilientOutputStreamBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/recovery/ResilientOutputStreamBaseTest.java new file mode 100644 index 000000000..6ab3e714b --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/recovery/ResilientOutputStreamBaseTest.java @@ -0,0 +1,286 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.recovery; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintStream; +import java.lang.reflect.Field; +import java.util.List; + +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.status.InfoStatus; +import ch.qos.logback.core.status.Status; + +public class ResilientOutputStreamBaseTest { + + /** A time far past any back-off period started by the recovery coordinator. */ + static final long FAR_FUTURE = Long.MAX_VALUE / 2; + static final String DESCRIPTION = "scripted stream"; + + Context context = new ContextBase(); + FaultyOutputStream target = new FaultyOutputStream(); + + /** An output stream whose writes and close can be made to fail. */ + static class FaultyOutputStream extends OutputStream { + final ByteArrayOutputStream written = new ByteArrayOutputStream(); + IOException writeFailure; + IOException closeFailure; + boolean closed; + + @Override + public void write(int b) throws IOException { + if (writeFailure != null) { + throw writeFailure; + } + written.write(b); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + if (writeFailure != null) { + throw writeFailure; + } + written.write(b, off, len); + } + + @Override + public void close() throws IOException { + closed = true; + if (closeFailure != null) { + throw closeFailure; + } + } + } + + /** A resilient stream whose replacement stream (or failure to open one) is set by the test. */ + static class ScriptedResilientOutputStream extends ResilientOutputStreamBase { + OutputStream nextStream; + IOException openFailure; + int openCount; + + ScriptedResilientOutputStream(OutputStream initial) { + this.os = initial; + } + + @Override + String getDescription() { + return DESCRIPTION; + } + + @Override + OutputStream openNewOutputStream() throws IOException { + openCount++; + if (openFailure != null) { + throw openFailure; + } + return nextStream; + } + } + + private ScriptedResilientOutputStream newStream(OutputStream initial) { + ScriptedResilientOutputStream stream = new ScriptedResilientOutputStream(initial); + stream.setContext(context); + return stream; + } + + private static RecoveryCoordinator recoveryCoordinatorOf(ResilientOutputStreamBase stream) throws Exception { + Field field = ResilientOutputStreamBase.class.getDeclaredField("recoveryCoordinator"); + field.setAccessible(true); + return (RecoveryCoordinator) field.get(stream); + } + + private Status lastStatus() { + List statuses = context.getStatusManager().getCopyOfStatusList(); + return statuses.get(statuses.size() - 1); + } + + private void assertLastStatus(int level, String message) { + Status status = lastStatus(); + assertEquals(level, status.getLevel()); + assertEquals(message, status.getMessage()); + } + + /** Makes the stream fail once so that it is presumed in error. */ + private void failOnce(ScriptedResilientOutputStream stream) { + target.writeFailure = new IOException("disk full"); + stream.write('x'); + target.writeFailure = null; + } + + @Test + public void singleByteIsWrittenToUnderlyingStream() { + ScriptedResilientOutputStream stream = newStream(target); + + stream.write('a'); + + assertArrayEquals(new byte[] {'a'}, target.written.toByteArray()); + assertEquals(0, context.getStatusManager().getCount()); + } + + @Test + public void failedSingleByteWriteIsReportedAsError() { + ScriptedResilientOutputStream stream = newStream(target); + IOException failure = new IOException("disk full"); + target.writeFailure = failure; + + stream.write('a'); + + assertLastStatus(Status.ERROR, "IO failure while writing to " + DESCRIPTION); + assertSame(failure, lastStatus().getThrowable()); + } + + @Test + public void failedByteArrayWriteIsReportedAsError() { + ScriptedResilientOutputStream stream = newStream(target); + IOException failure = new IOException("disk full"); + target.writeFailure = failure; + + stream.write(new byte[] {'a', 'b', 'c'}, 0, 3); + + assertLastStatus(Status.ERROR, "IO failure while writing to " + DESCRIPTION); + assertSame(failure, lastStatus().getThrowable()); + } + + @Test + public void singleByteWritesAreDroppedWhileBackingOff() throws Exception { + ScriptedResilientOutputStream stream = newStream(target); + failOnce(stream); + recoveryCoordinatorOf(stream).setCurrentTime(0); + + stream.write('b'); + + assertEquals(0, target.written.size()); + assertEquals(0, stream.openCount); + } + + @Test + public void singleByteWriteRecoversOnceBackOffHasElapsed() throws Exception { + ScriptedResilientOutputStream stream = newStream(target); + FaultyOutputStream replacement = new FaultyOutputStream(); + stream.nextStream = replacement; + failOnce(stream); + recoveryCoordinatorOf(stream).setCurrentTime(FAR_FUTURE); + + // this write triggers the recovery but is itself dropped + stream.write('b'); + + assertTrue(target.closed); + assertEquals(1, stream.openCount); + assertEquals(0, replacement.written.size()); + assertLastStatus(Status.INFO, "Attempting to recover from IO failure on " + DESCRIPTION); + + // the next write goes to the reopened stream and ends the failed state + stream.write('c'); + + assertArrayEquals(new byte[] {'c'}, replacement.written.toByteArray()); + assertLastStatus(Status.INFO, "Recovered from IO failure on " + DESCRIPTION); + } + + @Test + public void recoveryProceedsWhenClosingTheBrokenStreamFails() throws Exception { + ScriptedResilientOutputStream stream = newStream(target); + FaultyOutputStream replacement = new FaultyOutputStream(); + stream.nextStream = replacement; + target.closeFailure = new IOException("cannot close"); + failOnce(stream); + recoveryCoordinatorOf(stream).setCurrentTime(FAR_FUTURE); + + stream.write('b'); + stream.write('c'); + + assertTrue(target.closed); + assertEquals(1, stream.openCount); + assertArrayEquals(new byte[] {'c'}, replacement.written.toByteArray()); + } + + @Test + public void failureToReopenIsReportedAndStreamStaysInError() throws Exception { + ScriptedResilientOutputStream stream = newStream(target); + IOException openFailure = new IOException("cannot reopen"); + stream.openFailure = openFailure; + failOnce(stream); + recoveryCoordinatorOf(stream).setCurrentTime(FAR_FUTURE); + + stream.write('b'); + + assertLastStatus(Status.ERROR, "Failed to open " + DESCRIPTION); + assertSame(openFailure, lastStatus().getThrowable()); + + // still presumed in error: the write is not passed on to the old stream + stream.write('c'); + + assertEquals(0, target.written.size()); + } + + @Test + public void flushAndCloseWithoutUnderlyingStreamDoNothing() throws IOException { + ScriptedResilientOutputStream stream = newStream(null); + + stream.flush(); + stream.close(); + + assertEquals(0, context.getStatusManager().getCount()); + } + + @Test + public void statusesWithoutContextAreDroppedWithASingleConsoleWarning() throws Exception { + ScriptedResilientOutputStream stream = new ScriptedResilientOutputStream(target); + ByteArrayOutputStream console = new ByteArrayOutputStream(); + PrintStream originalOut = System.out; + System.setOut(new PrintStream(console, true, "UTF-8")); + try { + stream.addStatus(new InfoStatus("first", this)); + stream.addStatus(new InfoStatus("second", this)); + } finally { + System.setOut(originalOut); + } + + String output = console.toString("UTF-8"); + String warning = "LOGBACK: No context given for " + stream; + assertTrue(output, output.contains(warning)); + assertEquals(output, output.indexOf(warning), output.lastIndexOf(warning)); + } + + @Test + public void statusesAreDroppedWhenContextHasNoStatusManager() { + Context contextWithoutStatusManager = mock(Context.class); + ScriptedResilientOutputStream stream = new ScriptedResilientOutputStream(target); + stream.setContext(contextWithoutStatusManager); + + stream.addStatus(new InfoStatus("dropped", this)); + + verify(contextWithoutStatusManager).getStatusManager(); + } + + @Test + public void contextIsTheOneSet() { + ScriptedResilientOutputStream stream = newStream(target); + + assertSame(context, stream.getContext()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/recovery/ResilientSyslogOutputStreamTest.java b/logback-android/src/test/java/ch/qos/logback/core/recovery/ResilientSyslogOutputStreamTest.java new file mode 100644 index 000000000..b8ee5c99d --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/recovery/ResilientSyslogOutputStreamTest.java @@ -0,0 +1,109 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.recovery; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.nio.charset.Charset; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.net.SyslogOutputStream; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; + +public class ResilientSyslogOutputStreamTest { + + static final String LOOPBACK = "127.0.0.1"; + static final Charset UTF_8 = Charset.forName("UTF-8"); + + Context context = new ContextBase(); + DatagramSocket receiver; + int port; + ResilientSyslogOutputStream stream; + + @Before + public void setUp() throws IOException { + receiver = new DatagramSocket(0, InetAddress.getByName(LOOPBACK)); + receiver.setSoTimeout(10000); + port = receiver.getLocalPort(); + stream = new ResilientSyslogOutputStream(LOOPBACK, port); + stream.setContext(context); + } + + @After + public void tearDown() throws IOException { + stream.close(); + receiver.close(); + } + + private String receive() throws IOException { + byte[] buffer = new byte[1024]; + DatagramPacket packet = new DatagramPacket(buffer, buffer.length); + receiver.receive(packet); + return new String(packet.getData(), packet.getOffset(), packet.getLength(), UTF_8); + } + + private void send(String message) { + byte[] bytes = message.getBytes(UTF_8); + stream.write(bytes, 0, bytes.length); + stream.flush(); + } + + @Test + public void flushedBytesAreSentToSyslogHost() throws IOException { + send("hello"); + + assertEquals("hello", receive()); + } + + @Test + public void descriptionNamesHostAndPort() { + assertEquals("syslog [" + LOOPBACK + ":" + port + "]", stream.getDescription()); + } + + @Test + public void recoveryOpensNewStreamToSameHostAndPort() throws IOException { + OutputStream original = stream.os; + + stream.attemptRecovery(); + + assertNotSame(original, stream.os); + assertTrue(stream.os instanceof SyslogOutputStream); + assertEquals(port, ((SyslogOutputStream) stream.os).getPort()); + new StatusChecker(context).assertContainsMatch(Status.INFO, + "Attempting to recover from IO failure on syslog \\[" + LOOPBACK + ":" + port + "\\]"); + send("again"); + assertEquals("again", receive()); + } + + @Test + public void toStringIdentifiesTheInstance() { + assertEquals("c.q.l.c.recovery.ResilientSyslogOutputStream@" + System.identityHashCode(stream), + stream.toString()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/RollingFileAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/RollingFileAppenderTest.java index ddd36195d..48f62b1e4 100755 --- a/logback-android/src/test/java/ch/qos/logback/core/rolling/RollingFileAppenderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/RollingFileAppenderTest.java @@ -19,16 +19,29 @@ import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertTrue; +import static org.junit.Assert.assertSame; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; +import java.util.Map; import org.junit.After; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import ch.qos.logback.core.Appender; import ch.qos.logback.core.Context; import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.CoreConstants; import ch.qos.logback.core.appender.AbstractAppenderTest; import ch.qos.logback.core.encoder.DummyEncoder; +import ch.qos.logback.core.encoder.EchoEncoder; +import ch.qos.logback.core.rolling.helper.FileNamePattern; import ch.qos.logback.core.status.Status; import ch.qos.logback.core.status.StatusChecker; import ch.qos.logback.core.testUtil.RandomUtil; @@ -37,6 +50,9 @@ public class RollingFileAppenderTest extends AbstractAppenderTest { + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + RollingFileAppender rfa = new RollingFileAppender(); Context context = new ContextBase(); @@ -269,4 +285,282 @@ public void collidingFileNamePattern() { StatusChecker checker = new StatusChecker(context); checker.assertContainsMatch(Status.ERROR, "'FileNamePattern' option has the same value"); } + + /** A rolling policy (not a triggering policy) whose rollover runs a test-supplied action. */ + static class ScriptedRollingPolicy extends RollingPolicyBase { + interface Action { + void run() throws RolloverFailure; + } + + Action onRollover; + + @Override + public void rollover() throws RolloverFailure { + onRollover.run(); + } + + @Override + public String getActiveFileName() { + return getParentsRawFileProperty(); + } + } + + /** A rolling policy that is its own triggering policy and uses no file name pattern. */ + static class PatternlessRollingPolicy extends RollingPolicyBase implements TriggeringPolicy { + @Override + public void rollover() { + } + + @Override + public String getActiveFileName() { + return getParentsRawFileProperty(); + } + + @Override + public boolean isTriggeringEvent(File activeFile, Object event) { + return false; + } + } + + private String tmpPath(String name) { + return new File(tmp.getRoot(), name).getAbsolutePath(); + } + + private static String read(String fileName) throws IOException { + return new String(Files.readAllBytes(new File(fileName).toPath()), "UTF-8"); + } + + private SizeBasedTriggeringPolicy startedSizeBasedTriggeringPolicy() { + SizeBasedTriggeringPolicy policy = new SizeBasedTriggeringPolicy(); + policy.setContext(context); + policy.start(); + return policy; + } + + private ScriptedRollingPolicy startAppenderWithScriptedPolicy(String fileName) { + rfa.setContext(context); + rfa.setEncoder(new EchoEncoder()); + rfa.setFile(fileName); + ScriptedRollingPolicy policy = new ScriptedRollingPolicy(); + policy.setContext(context); + policy.setParent(rfa); + policy.start(); + rfa.setRollingPolicy(policy); + rfa.setTriggeringPolicy(startedSizeBasedTriggeringPolicy()); + rfa.start(); + assertTrue(rfa.isStarted()); + return policy; + } + + private TimeBasedRollingPolicy startedTimeBasedRollingPolicy(RollingFileAppender appender, String pattern) { + TimeBasedRollingPolicy policy = new TimeBasedRollingPolicy(); + policy.setContext(context); + policy.setFileNamePattern(pattern); + policy.setParent(appender); + policy.start(); + return policy; + } + + @Test + public void startWithoutTriggeringPolicyIsRefused() { + rfa.setContext(context); + rfa.setFile(tmpPath("no-tp.log")); + + rfa.start(); + + assertFalse(rfa.isStarted()); + StatusChecker checker = new StatusChecker(context); + checker.assertContainsMatch(Status.WARN, "No TriggeringPolicy was set for the RollingFileAppender named test"); + checker.assertContainsMatch(Status.WARN, "For more information, please visit .*#rfa_no_tp"); + assertFalse(new File(tmpPath("no-tp.log")).exists()); + } + + @Test + public void startWithoutRollingPolicyIsRefused() { + rfa.setContext(context); + rfa.setFile(tmpPath("no-rp.log")); + rfa.setTriggeringPolicy(startedSizeBasedTriggeringPolicy()); + + rfa.start(); + + assertFalse(rfa.isStarted()); + StatusChecker checker = new StatusChecker(context); + checker.assertContainsMatch(Status.ERROR, "No RollingPolicy was set for the RollingFileAppender named test"); + checker.assertContainsMatch(Status.ERROR, "For more information, please visit .*#rfa_no_rp"); + assertFalse(new File(tmpPath("no-rp.log")).exists()); + } + + @Test + public void stopWithoutRollingPolicyStillStopsTriggeringPolicy() { + rfa.setContext(context); + SizeBasedTriggeringPolicy triggeringPolicy = startedSizeBasedTriggeringPolicy(); + rfa.setTriggeringPolicy(triggeringPolicy); + + rfa.stop(); + + assertNull(rfa.getRollingPolicy()); + assertFalse(triggeringPolicy.isStarted()); + } + + @Test + public void stopWithoutTriggeringPolicyStillStopsRollingPolicy() { + rfa.setContext(context); + rfa.setFile(tmpPath("fw.log")); + FixedWindowRollingPolicy rollingPolicy = new FixedWindowRollingPolicy(); + rollingPolicy.setContext(context); + rollingPolicy.setFileNamePattern(tmpPath("fw.%i.log")); + rollingPolicy.setParent(rfa); + rollingPolicy.start(); + rfa.setRollingPolicy(rollingPolicy); + + rfa.stop(); + + assertNull(rfa.getTriggeringPolicy()); + assertFalse(rollingPolicy.isStarted()); + } + + @Test + public void setTriggeringPolicyThatIsAlsoRollingPolicySetsBoth() { + rfa.setTriggeringPolicy(tbrp); + + assertSame(tbrp, rfa.getTriggeringPolicy()); + assertSame(tbrp, rfa.getRollingPolicy()); + } + + @Test + public void setRollingPolicyThatIsNotTriggeringPolicyLeavesTriggeringPolicyUnset() { + FixedWindowRollingPolicy rollingPolicy = new FixedWindowRollingPolicy(); + + rfa.setRollingPolicy(rollingPolicy); + + assertSame(rollingPolicy, rfa.getRollingPolicy()); + assertNull(rfa.getTriggeringPolicy()); + } + + @Test + public void testFilePropertyAfterNonTriggeringRollingPolicy() { + rfa.setContext(context); + rfa.setRollingPolicy(new FixedWindowRollingPolicy()); + rfa.setFile("x"); + StatusChecker statusChecker = new StatusChecker(context.getStatusManager()); + statusChecker.assertContainsMatch(Status.ERROR, + "File property must be set before any triggeringPolicy "); + } + + @Test + public void appendersWithDistinctFileNamePatternsDoNotCollide() { + RollingFileAppender appender0 = new RollingFileAppender(); + appender0.setName("FA0"); + appender0.setContext(context); + appender0.setEncoder(new DummyEncoder()); + appender0.setRollingPolicy(startedTimeBasedRollingPolicy(appender0, tmpPath("a-%d.log"))); + appender0.start(); + + rfa.setContext(context); + rfa.setRollingPolicy(startedTimeBasedRollingPolicy(rfa, tmpPath("b-%d.log"))); + rfa.start(); + + assertTrue(appender0.isStarted()); + assertTrue(rfa.isStarted()); + new StatusChecker(context).assertNoMatch("'FileNamePattern' option has the same value"); + appender0.stop(); + rfa.stop(); + } + + @Test + public void startsWithoutCollisionCheckWhenContextHasNoFileNamePatternMap() { + context.putObject(CoreConstants.RFA_FILENAME_PATTERN_COLLISION_MAP, null); + rfa.setContext(context); + rfa.setRollingPolicy(startedTimeBasedRollingPolicy(rfa, tmpPath("c-%d.log"))); + + rfa.start(); + assertTrue(rfa.isStarted()); + + rfa.stop(); + assertFalse(rfa.isStarted()); + } + + @Test + public void stoppingUnnamedAppenderKeepsOtherAppendersInCollisionMap() { + @SuppressWarnings("unchecked") + Map map = + (Map) context.getObject(CoreConstants.RFA_FILENAME_PATTERN_COLLISION_MAP); + FileNamePattern otherPattern = new FileNamePattern(tmpPath("other-%d.log"), context); + map.put("other", otherPattern); + rfa.setName(null); + rfa.setContext(context); + rfa.setRollingPolicy(startedTimeBasedRollingPolicy(rfa, tmpPath("d-%d.log"))); + rfa.start(); + assertTrue(rfa.isStarted()); + + rfa.stop(); + + assertEquals(1, map.size()); + assertSame(otherPattern, map.get("other")); + } + + @Test + public void rollingPolicyWithoutFileNamePatternSkipsPatternChecks() throws IOException { + rfa.setContext(context); + rfa.setEncoder(new EchoEncoder()); + rfa.setFile(tmpPath("patternless.log")); + PatternlessRollingPolicy policy = new PatternlessRollingPolicy(); + policy.setContext(context); + policy.setParent(rfa); + policy.start(); + rfa.setRollingPolicy(policy); + + rfa.start(); + rfa.doAppend("hello"); + rfa.stop(); + + assertSame(policy, rfa.getTriggeringPolicy()); + assertEquals("hello" + CoreConstants.LINE_SEPARATOR, read(tmpPath("patternless.log"))); + } + + @Test + public void failedRolloverKeepsAppendingToActiveFile() throws IOException { + String fileName = tmpPath("deferred.log"); + ScriptedRollingPolicy policy = startAppenderWithScriptedPolicy(fileName); + policy.onRollover = new ScriptedRollingPolicy.Action() { + @Override + public void run() throws RolloverFailure { + throw new RolloverFailure("cannot rename"); + } + }; + rfa.doAppend("before"); + rfa.setAppend(false); + + rfa.rollover(); + rfa.doAppend("after"); + rfa.stop(); + + assertTrue(rfa.isAppend()); + new StatusChecker(context).assertContainsMatch(Status.WARN, "RolloverFailure occurred. Deferring roll-over."); + assertEquals("before" + CoreConstants.LINE_SEPARATOR + "after" + CoreConstants.LINE_SEPARATOR, read(fileName)); + } + + @Test + public void failureToReopenActiveFileAfterRolloverIsReported() { + final File activeFile = new File(tmpPath("reopen.log")); + ScriptedRollingPolicy policy = startAppenderWithScriptedPolicy(activeFile.getAbsolutePath()); + policy.onRollover = new ScriptedRollingPolicy.Action() { + @Override + public void run() { + // leave something that cannot be opened for writing where the active file was + assertTrue(activeFile.delete()); + assertTrue(activeFile.mkdir()); + } + }; + + rfa.rollover(); + + List statuses = context.getStatusManager().getCopyOfStatusList(); + Status last = statuses.get(statuses.size() - 1); + assertEquals(Status.ERROR, last.getLevel()); + assertEquals("setFile(" + activeFile.getAbsolutePath() + ", false) call failed.", last.getMessage()); + assertTrue(String.valueOf(last.getThrowable()), last.getThrowable() instanceof FileNotFoundException); + assertEquals(activeFile, rfa.currentlyActiveFile); + rfa.stop(); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/RollingPolicyBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/RollingPolicyBaseTest.java new file mode 100644 index 000000000..f1fc934f2 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/RollingPolicyBaseTest.java @@ -0,0 +1,50 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.rolling; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +public class RollingPolicyBaseTest { + + /** The smallest concrete rolling policy: everything under test lives in the base class. */ + static class MinimalRollingPolicy extends RollingPolicyBase { + @Override + public void rollover() { + } + + @Override + public String getActiveFileName() { + return null; + } + } + + MinimalRollingPolicy policy = new MinimalRollingPolicy(); + + @Test + public void fileNamePatternIsUnsetByDefault() { + assertNull(policy.getFileNamePattern()); + } + + @Test + public void fileNamePatternIsTheOneSet() { + policy.setFileNamePattern("logs/app-%d{yyyy-MM-dd}.log.gz"); + + assertEquals("logs/app-%d{yyyy-MM-dd}.log.gz", policy.getFileNamePattern()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/RolloverFailureTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/RolloverFailureTest.java new file mode 100644 index 000000000..ece8b28aa --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/RolloverFailureTest.java @@ -0,0 +1,45 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.rolling; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +import java.io.IOException; + +import org.junit.Test; + +public class RolloverFailureTest { + + @Test + public void messageOnlyFailureHasNoCause() { + RolloverFailure failure = new RolloverFailure("rename failed"); + + assertEquals("rename failed", failure.getMessage()); + assertNull(failure.getCause()); + } + + @Test + public void failureKeepsMessageAndCause() { + IOException cause = new IOException("disk full"); + + RolloverFailure failure = new RolloverFailure("rename failed", cause); + + assertEquals("rename failed", failure.getMessage()); + assertSame(cause, failure.getCause()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/SizeAndTimeBasedFNATP_Test.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/SizeAndTimeBasedFNATP_Test.java index 70afc92cf..aebd688e3 100755 --- a/logback-android/src/test/java/ch/qos/logback/core/rolling/SizeAndTimeBasedFNATP_Test.java +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/SizeAndTimeBasedFNATP_Test.java @@ -16,22 +16,41 @@ package ch.qos.logback.core.rolling; import ch.qos.logback.core.encoder.EchoEncoder; +import ch.qos.logback.core.rolling.helper.FileFilterUtil; +import ch.qos.logback.core.rolling.helper.FileNamePattern; import ch.qos.logback.core.status.InfoStatus; +import ch.qos.logback.core.status.Status; import ch.qos.logback.core.status.StatusChecker; import ch.qos.logback.core.status.StatusManager; import ch.qos.logback.core.util.FileSize; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; +import org.mockito.Mockito; import java.io.File; import java.io.IOException; +import java.nio.file.Files; import java.util.Date; import java.util.List; import java.util.concurrent.ExecutionException; +import java.util.regex.Pattern; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; public class SizeAndTimeBasedFNATP_Test extends ScaffoldingForRollingTests { + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + private SizeAndTimeBasedFNATP sizeAndTimeBasedFNATP = null; private RollingFileAppender rfa1 = new RollingFileAppender(); private TimeBasedRollingPolicy tbrp1 = new TimeBasedRollingPolicy(); @@ -224,6 +243,122 @@ public void checkDateCollision() { checker.assertContainsMatch("The date format in FileNamePattern"); } + /** + * Starts {@link #tbrp1} (but not its appender) with a size-and-time based + * policy in embedded mode, as SizeAndTimeBasedRollingPolicy does. + */ + private SizeAndTimeBasedFNATP startTimeBasedRollingPolicy(String fileNamePattern, FileSize maxFileSize) { + SizeAndTimeBasedFNATP fnatp = new SizeAndTimeBasedFNATP(SizeAndTimeBasedFNATP.Usage.EMBEDDED); + fnatp.setMaxFileSize(maxFileSize); + fnatp.setCurrentTime(currentTime); + tbrp1.setContext(context); + tbrp1.setParent(rfa1); + tbrp1.setFileNamePattern(fileNamePattern); + tbrp1.setTimeBasedFileNamingAndTriggeringPolicy(fnatp); + tbrp1.start(); + return fnatp; + } + + private String tmpDir() { + return tmp.getRoot().getAbsolutePath() + "/"; + } + + private File fileOfLength(String name, int length) throws IOException { + File file = new File(tmpDir() + name); + Files.write(file.toPath(), new byte[length]); + return file; + } + + @Test + public void checkMissingDateToken() { + // TimeBasedFileNamingAndTriggeringPolicyBase.start() already refuses a pattern without a + // date token, so simulate a pattern whose date token is gone by the time tokens are validated + String fileNamePattern = tmpDir() + "checkMissingDateToken-%d{" + DATE_PATTERN_WITH_SECONDS + ", GMT}-%i.txt"; + startTimeBasedRollingPolicy(fileNamePattern, new FileSize(300)); + FileNamePattern pattern = spy(tbrp1.fileNamePattern); + doCallRealMethod().doReturn(null).when(pattern).getPrimaryDateTokenConverter(); + tbrp1.fileNamePattern = pattern; + SizeAndTimeBasedFNATP fnatp = new SizeAndTimeBasedFNATP(SizeAndTimeBasedFNATP.Usage.EMBEDDED); + fnatp.setContext(context); + fnatp.setMaxFileSize(new FileSize(300)); + fnatp.setCurrentTime(currentTime); + fnatp.setTimeBasedRollingPolicy(tbrp1); + + fnatp.start(); + + assertFalse(fnatp.isStarted()); + StatusChecker checker = new StatusChecker(context); + checker.assertContainsMatch(Status.ERROR, + Pattern.quote(SizeAndTimeBasedFNATP.MISSING_DATE_TOKEN + fileNamePattern + "]")); + } + + @Test + public void checkMissingMaxFileSize() { + SizeAndTimeBasedFNATP fnatp = startTimeBasedRollingPolicy( + tmpDir() + "checkMissingMaxFileSize-%d{" + DATE_PATTERN_WITH_SECONDS + ", GMT}-%i.txt", null); + + assertFalse(fnatp.isStarted()); + assertFalse(tbrp1.isStarted()); + new StatusChecker(context).assertContainsMatch(Status.ERROR, "maxFileSize property is mandatory."); + } + + @Test + public void missingActiveFileDoesNotTriggerWithinPeriod() { + SizeAndTimeBasedFNATP fnatp = startTimeBasedRollingPolicy( + tmpDir() + "nullActiveFile-%d{" + DATE_PATTERN_WITH_SECONDS + ", GMT}-%i.txt", new FileSize(1)); + assertTrue(fnatp.isStarted()); + + assertFalse(fnatp.isTriggeringEvent(null, null)); + + new StatusChecker(context).assertContainsMatch(Status.WARN, "activeFile == null"); + assertEquals(0, fnatp.currentPeriodsCounter); + } + + @Test + public void missingMaxFileSizeDoesNotTriggerWithinPeriod() throws IOException { + SizeAndTimeBasedFNATP fnatp = startTimeBasedRollingPolicy( + tmpDir() + "nullMaxFileSize-%d{" + DATE_PATTERN_WITH_SECONDS + ", GMT}-%i.txt", new FileSize(1)); + assertTrue(fnatp.isStarted()); + File activeFile = fileOfLength("nullMaxFileSize.log", 10); + fnatp.setMaxFileSize(null); + + assertFalse(fnatp.isTriggeringEvent(activeFile, null)); + + new StatusChecker(context).assertContainsMatch(Status.WARN, "maxFileSize = null"); + assertEquals(0, fnatp.currentPeriodsCounter); + } + + @Test + public void compressedArchivesOfCurrentPeriodAdvanceCounterWhenFileIsBlank() throws IOException { + // currentTime is 2018-07-11 12:30 GMT + fileOfLength("blankFile-2018-07-11-0.log.gz", 1); + fileOfLength("blankFile-2018-07-11-3.log.gz", 1); + + SizeAndTimeBasedFNATP fnatp = startTimeBasedRollingPolicy( + tmpDir() + "blankFile-%d{yyyy-MM-dd, GMT}-%i.log.gz", new FileSize(100)); + + assertTrue(fnatp.isStarted()); + assertEquals(4, fnatp.currentPeriodsCounter); + assertEquals(tmpDir() + "blankFile-2018-07-11-4.log", tbrp1.getActiveFileName()); + } + + @Test + public void unlistableArchiveFolderResetsCounter() { + SizeAndTimeBasedFNATP fnatp = startTimeBasedRollingPolicy( + tmpDir() + "unlistable-%d{" + DATE_PATTERN_WITH_SECONDS + ", GMT}-%i.txt", new FileSize(100)); + fnatp.currentPeriodsCounter = 5; + + try (MockedStatic fileFilterUtil = mockStatic(FileFilterUtil.class, Mockito.CALLS_REAL_METHODS)) { + // File.listFiles() returns null when the folder cannot be read + fileFilterUtil.when(() -> FileFilterUtil.filesInFolderMatchingStemRegex(any(File.class), anyString())) + .thenReturn(null); + + fnatp.computeCurrentPeriodsHighestCounterValue("unlistable-.*"); + } + + assertEquals(0, fnatp.currentPeriodsCounter); + } + // @Test // public void testHistoryAsFileCount() throws IOException { // String testId = "testHistoryAsFileCount"; diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/SizeAndTimeBasedRollingPolicyTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/SizeAndTimeBasedRollingPolicyTest.java new file mode 100644 index 000000000..ff1ea9574 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/SizeAndTimeBasedRollingPolicyTest.java @@ -0,0 +1,105 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.rolling; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.util.regex.Pattern; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; +import ch.qos.logback.core.util.FileSize; + +public class SizeAndTimeBasedRollingPolicyTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + Context context = new ContextBase(); + StatusChecker checker = new StatusChecker(context); + RollingFileAppender rfa = new RollingFileAppender(); + SizeAndTimeBasedRollingPolicy policy = new SizeAndTimeBasedRollingPolicy(); + + @Before + public void setUp() { + rfa.setContext(context); + policy.setContext(context); + policy.setParent(rfa); + policy.setFileNamePattern(new File(tmp.getRoot(), "app-%d{yyyy-MM-dd}.%i.log").getAbsolutePath()); + } + + @Test + public void startWithoutMaxFileSizeIsRefused() { + policy.start(); + + assertFalse(policy.isStarted()); + assertNull(policy.getTimeBasedFileNamingAndTriggeringPolicy()); + checker.assertContainsMatch(Status.ERROR, "maxFileSize property is mandatory"); + } + + @Test + public void totalSizeCapBelowMaxFileSizeIsRefused() { + policy.setMaxFileSize(new FileSize(2048)); + policy.setTotalSizeCap(new FileSize(1024)); + + policy.start(); + + assertFalse(policy.isStarted()); + checker.assertContainsMatch(Status.ERROR, + Pattern.quote("totalSizeCap of [1 KB] is smaller than maxFileSize [2 KB] which is non-sensical")); + } + + @Test + public void totalSizeCapOfAtLeastMaxFileSizeIsAccepted() { + policy.setMaxFileSize(new FileSize(1024)); + policy.setTotalSizeCap(new FileSize(1024)); + + policy.start(); + + assertTrue(policy.isStarted()); + checker.assertContainsMatch(Status.INFO, Pattern.quote("Archive files will be limited to [1 KB] each.")); + SizeAndTimeBasedFNATP fnatp = (SizeAndTimeBasedFNATP) policy.getTimeBasedFileNamingAndTriggeringPolicy(); + assertSame(policy.maxFileSize, fnatp.maxFileSize); + policy.stop(); + } + + @Test + public void unboundedTotalSizeCapIsAccepted() { + policy.setMaxFileSize(new FileSize(1024)); + + policy.start(); + + assertTrue(policy.isStarted()); + policy.stop(); + } + + @Test + public void toStringIdentifiesTheInstance() { + assertEquals("c.q.l.core.rolling.SizeAndTimeBasedRollingPolicy@" + policy.hashCode(), policy.toString()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/SizeBasedTriggeringPolicyTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/SizeBasedTriggeringPolicyTest.java new file mode 100644 index 000000000..5d5d4ed12 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/SizeBasedTriggeringPolicyTest.java @@ -0,0 +1,68 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.rolling; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import ch.qos.logback.core.util.FileSize; + +public class SizeBasedTriggeringPolicyTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + SizeBasedTriggeringPolicy policy = new SizeBasedTriggeringPolicy(); + + private File fileOfLength(int length) throws IOException { + File file = tmp.newFile(); + Files.write(file.toPath(), new byte[length]); + return file; + } + + @Test + public void defaultMaxFileSizeIsTenMegabytes() { + assertEquals(10 * 1024 * 1024, policy.getMaxFileSize().getSize()); + assertEquals(SizeBasedTriggeringPolicy.DEFAULT_MAX_FILE_SIZE, policy.getMaxFileSize().getSize()); + } + + @Test + public void maxFileSizeIsTheOneSet() { + FileSize maxFileSize = new FileSize(100); + + policy.setMaxFileSize(maxFileSize); + + assertSame(maxFileSize, policy.getMaxFileSize()); + } + + @Test + public void triggersOnceActiveFileReachesMaxFileSize() throws IOException { + policy.setMaxFileSize(new FileSize(100)); + + assertFalse(policy.isTriggeringEvent(fileOfLength(99), null)); + assertTrue(policy.isTriggeringEvent(fileOfLength(100), null)); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/TimeBasedFileNamingAndTriggeringPolicyBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/TimeBasedFileNamingAndTriggeringPolicyBaseTest.java index 047af1307..69de3e2c8 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/rolling/TimeBasedFileNamingAndTriggeringPolicyBaseTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/TimeBasedFileNamingAndTriggeringPolicyBaseTest.java @@ -21,11 +21,18 @@ import ch.qos.logback.core.status.StatusChecker; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; /** * @author Ceki Gücü @@ -35,6 +42,9 @@ public class TimeBasedFileNamingAndTriggeringPolicyBaseTest { static long MILLIS_IN_MINUTE = 60*1000; static long MILLIS_IN_HOUR = 60*MILLIS_IN_MINUTE; + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + Context context = new ContextBase(); RollingFileAppender rfa = new RollingFileAppender(); TimeBasedRollingPolicy tbrp = new TimeBasedRollingPolicy(); @@ -115,4 +125,60 @@ public void extraIntegerTokenInFileNamePatternShouldBeDetected() { StatusChecker statusChecker = new StatusChecker(context); statusChecker.assertContainsMatch(Status.ERROR, "Filename pattern .{37} contains an integer token converter"); } + + @Test + public void fileNamePatternWithoutDateTokenIsRejected() { + tbrp.setFileNamePattern("foo.log"); + + IllegalStateException e = assertThrows(IllegalStateException.class, tbrp::start); + + assertEquals("FileNamePattern [foo.log] does not contain a valid DateToken", e.getMessage()); + assertFalse(timeBasedFNATP.isStarted()); + } + + @Test + public void stopMarksPolicyAsNotStarted() { + tbrp.setFileNamePattern("foo-%d{yyyy-MM-dd}.log"); + tbrp.start(); + assertTrue(timeBasedFNATP.isStarted()); + + timeBasedFNATP.stop(); + + assertFalse(timeBasedFNATP.isStarted()); + } + + @Test + public void readableActiveFileDeterminesInitialPeriod() throws IOException { + // Friday January 7th 12:00:00 UTC 2000 + File activeFile = tmp.newFile("active.log"); + assertTrue(activeFile.setLastModified(947246400000L)); + rfa.setFile(activeFile.getAbsolutePath()); + tbrp.setFileNamePattern("foo-%d{yyyy-MM-dd, UTC}.log"); + timeBasedFNATP.setCurrentTime(1324400341553L); + + tbrp.start(); + + assertEquals("foo-2000-01-07.log", timeBasedFNATP.getCurrentPeriodsFileNameWithoutCompressionSuffix()); + } + + @Test + public void unreadableActiveFileDoesNotDetermineInitialPeriod() throws IOException { + // Friday January 7th 12:00:00 UTC 2000 + File activeFile = tmp.newFile("active.log"); + assertTrue(activeFile.setLastModified(947246400000L)); + // read permissions cannot be withdrawn on some platforms, nor from a privileged user + assumeTrue(activeFile.setReadable(false, false) && !activeFile.canRead()); + try { + rfa.setFile(activeFile.getAbsolutePath()); + tbrp.setFileNamePattern("foo-%d{yyyy-MM-dd, UTC}.log"); + // Tuesday December 20th 16:59:01 UTC 2011 + timeBasedFNATP.setCurrentTime(1324400341553L); + + tbrp.start(); + + assertEquals("foo-2011-12-20.log", timeBasedFNATP.getCurrentPeriodsFileNameWithoutCompressionSuffix()); + } finally { + activeFile.setReadable(true, false); + } + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/TimeBasedRollingPolicyTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/TimeBasedRollingPolicyTest.java new file mode 100644 index 000000000..1d7c18682 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/TimeBasedRollingPolicyTest.java @@ -0,0 +1,186 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.rolling; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.regex.Pattern; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; +import ch.qos.logback.core.util.FileSize; + +public class TimeBasedRollingPolicyTest { + + // 2020-06-15T12:00:00Z + static final long NOW = 1592222400000L; + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + ContextBase context = new ContextBase(); + StatusChecker checker = new StatusChecker(context); + RollingFileAppender rfa = new RollingFileAppender(); + TimeBasedRollingPolicy tbrp = new TimeBasedRollingPolicy(); + DefaultTimeBasedFileNamingAndTriggeringPolicy fnatp = new DefaultTimeBasedFileNamingAndTriggeringPolicy(); + + @Before + public void setUp() { + rfa.setContext(context); + tbrp.setContext(context); + tbrp.setParent(rfa); + fnatp.setCurrentTime(NOW); + tbrp.setTimeBasedFileNamingAndTriggeringPolicy(fnatp); + } + + @After + public void tearDown() { + // stops the executor running asynchronous clean-up jobs, if any was started + context.stop(); + } + + private String path(String name) { + return new File(tmp.getRoot(), name).getAbsolutePath(); + } + + private Status lastStatus() { + List statuses = context.getStatusManager().getCopyOfStatusList(); + return statuses.get(statuses.size() - 1); + } + + private void startDaily() { + tbrp.setFileNamePattern(path("app-%d{yyyy-MM-dd, UTC}.log")); + tbrp.start(); + assertTrue(tbrp.isStarted()); + } + + @SuppressWarnings("unchecked") + private static Future futureFailingWith(Exception failure) throws Exception { + Future future = mock(Future.class); + when(future.get(anyLong(), any(TimeUnit.class))).thenThrow(failure); + return future; + } + + @Test + public void startWithoutFileNamePatternIsRejected() { + IllegalStateException e = assertThrows(IllegalStateException.class, tbrp::start); + + assertEquals(TimeBasedRollingPolicy.FNP_NOT_SET + CoreConstants.SEE_FNP_NOT_SET, e.getMessage()); + checker.assertContainsMatch(Status.WARN, Pattern.quote(TimeBasedRollingPolicy.FNP_NOT_SET)); + checker.assertContainsMatch(Status.WARN, Pattern.quote(CoreConstants.SEE_FNP_NOT_SET)); + assertFalse(tbrp.isStarted()); + } + + @Test + public void historyIsUnboundedAndNotCleanedOnStartByDefault() { + assertEquals(CoreConstants.UNBOUND_HISTORY, tbrp.getMaxHistory()); + assertFalse(tbrp.isCleanHistoryOnStart()); + } + + @Test + public void cleanHistoryOnStartRemovesArchivesOlderThanMaxHistory() throws Exception { + File expired = tmp.newFile("app-2020-06-01.log"); + File retained = tmp.newFile("app-2020-06-14.log"); + tbrp.setMaxHistory(2); + tbrp.setCleanHistoryOnStart(true); + + startDaily(); + + assertEquals(2, tbrp.getMaxHistory()); + assertTrue(tbrp.isCleanHistoryOnStart()); + checker.assertContainsMatch(Status.INFO, "Cleaning on start up"); + assertNotNull(tbrp.cleanUpFuture); + tbrp.cleanUpFuture.get(CoreConstants.SECONDS_TO_WAIT_FOR_COMPRESSION_JOBS, TimeUnit.SECONDS); + assertFalse(expired.exists()); + assertTrue(retained.exists()); + } + + @Test + public void maxHistoryWithoutCleanHistoryOnStartLeavesArchivesUntilRollover() throws IOException { + File expired = tmp.newFile("app-2020-06-01.log"); + tbrp.setMaxHistory(2); + + startDaily(); + + assertNull(tbrp.cleanUpFuture); + assertTrue(expired.exists()); + } + + @Test + public void totalSizeCapWithoutMaxHistoryIsIgnored() { + tbrp.setTotalSizeCap(new FileSize(1024)); + + startDaily(); + + checker.assertContainsMatch(Status.WARN, + Pattern.quote("'maxHistory' is not set, ignoring 'totalSizeCap' option with value [1 KB]")); + } + + @Test + public void stopReportsCompressionJobThatTimesOut() throws Exception { + startDaily(); + TimeoutException timeout = new TimeoutException(); + tbrp.compressionFuture = futureFailingWith(timeout); + + tbrp.stop(); + + assertFalse(tbrp.isStarted()); + Status status = lastStatus(); + assertEquals(Status.ERROR, status.getLevel()); + assertEquals("Timeout while waiting for compression job to finish", status.getMessage()); + assertSame(timeout, status.getThrowable()); + } + + @Test + public void stopReportsCleanUpJobThatFails() throws Exception { + startDaily(); + ExecutionException failure = new ExecutionException(new IOException("cannot delete")); + tbrp.cleanUpFuture = futureFailingWith(failure); + + tbrp.stop(); + + assertFalse(tbrp.isStarted()); + Status status = lastStatus(); + assertEquals(Status.ERROR, status.getLevel()); + assertEquals("Unexpected exception while waiting for clean-up job to finish", status.getMessage()); + assertSame(failure, status.getThrowable()); + } +} From e10ddafec46b46ba52e44a9a347d040a6292259b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:35:58 +0000 Subject: [PATCH 67/79] test: pin window boundaries and the one-time no-context warning FixedWindowRollingPolicyTest now covers a window of exactly the maximum size, a subclass raising getMaxWindowSize(), and a single-index window at 0, so that shifting the maxIndex/minIndex, window-size or maxIndex >= 0 comparisons makes a test fail. ResilientOutputStreamBaseTest checks that the console warning appears on the first status dropped for lack of a context and not on later ones; before, printing it only on the second status went unnoticed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../ResilientOutputStreamBaseTest.java | 12 +++--- .../rolling/FixedWindowRollingPolicyTest.java | 42 +++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/logback-android/src/test/java/ch/qos/logback/core/recovery/ResilientOutputStreamBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/recovery/ResilientOutputStreamBaseTest.java index 6ab3e714b..8dfd84285 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/recovery/ResilientOutputStreamBaseTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/recovery/ResilientOutputStreamBaseTest.java @@ -252,18 +252,20 @@ public void statusesWithoutContextAreDroppedWithASingleConsoleWarning() throws E ScriptedResilientOutputStream stream = new ScriptedResilientOutputStream(target); ByteArrayOutputStream console = new ByteArrayOutputStream(); PrintStream originalOut = System.out; + String warning = "LOGBACK: No context given for " + stream; System.setOut(new PrintStream(console, true, "UTF-8")); try { + // the first status is dropped with a warning... stream.addStatus(new InfoStatus("first", this)); + String afterFirst = console.toString("UTF-8"); + assertTrue(afterFirst, afterFirst.contains(warning)); + + // ...and later ones are dropped silently stream.addStatus(new InfoStatus("second", this)); + assertEquals(afterFirst, console.toString("UTF-8")); } finally { System.setOut(originalOut); } - - String output = console.toString("UTF-8"); - String warning = "LOGBACK: No context given for " + stream; - assertTrue(output, output.contains(warning)); - assertEquals(output, output.indexOf(warning), output.lastIndexOf(warning)); } @Test diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/FixedWindowRollingPolicyTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/FixedWindowRollingPolicyTest.java index e59c67c21..3c448ecd9 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/rolling/FixedWindowRollingPolicyTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/FixedWindowRollingPolicyTest.java @@ -146,6 +146,48 @@ public void oversizedWindowIsReducedToMaxWindowSize() { checker.assertContainsMatch(Status.WARN, "MaxIndex reduced to 22"); } + @Test + public void windowOfMaxWindowSizeIsKept() { + startWithWindow(path("archive.%i.log"), 1, 21); + + assertTrue(fwrp.isStarted()); + assertEquals(1, fwrp.getMinIndex()); + assertEquals(21, fwrp.getMaxIndex()); + checker.assertIsWarningOrErrorFree(); + } + + @Test + public void subclassCanRaiseMaxWindowSize() { + fwrp = new FixedWindowRollingPolicy() { + @Override + protected int getMaxWindowSize() { + return 50; + } + }; + fwrp.setContext(context); + fwrp.setParent(rfa); + + startWithWindow(path("archive.%i.log"), 1, 41); + + assertTrue(fwrp.isStarted()); + assertEquals(41, fwrp.getMaxIndex()); + checker.assertIsWarningOrErrorFree(); + } + + @Test + public void singleIndexWindowAtZeroRollsActiveFileToIndexZero() throws IOException { + startWithWindow(path("archive.%i.log"), 0, 0); + checker.assertIsWarningOrErrorFree(); + write(activeFileName, "active"); + write(path("archive.0.log"), "previous"); + + fwrp.rollover(); + + assertFalse(new File(activeFileName).exists()); + assertEquals("active", read(path("archive.0.log"))); + assertEquals(1, tmp.getRoot().list().length); + } + @Test public void fileNamePatternWithoutIntegerTokenIsRejected() { rfa.setFile(activeFileName); From af9f9af5381ffd6110ec7d07928379799898abfb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:50:11 +0000 Subject: [PATCH 68/79] refactor: drop unreachable FormatInfo.valueOf part checks In FormatInfo.valueOf, minPart is assigned on both branches of the dot check (the prefix before the dot, or the whole string), so the `minPart != null` test can never be false. A non-null maxPart is the suffix after the dot, and a string ending in '.' is rejected with an IllegalArgumentException before it is assigned, so `maxPart.length() > 0` can never be false either. Both dead operands left branches that no input can reach, which the 100% branch coverage gate cannot accept. Removing them keeps the result identical for every input: an empty min part still leaves the default min and left padding, a missing max part still leaves the default max and left truncation, and a trailing '.' is still rejected. FormatInfoTest now pins those cases. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../qos/logback/core/pattern/FormatInfo.java | 6 ++- .../core/pattern/parser/FormatInfoTest.java | 49 +++++++++++++++++-- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/logback-android/src/main/java/ch/qos/logback/core/pattern/FormatInfo.java b/logback-android/src/main/java/ch/qos/logback/core/pattern/FormatInfo.java index c4e071fc5..03e46b30f 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/pattern/FormatInfo.java +++ b/logback-android/src/main/java/ch/qos/logback/core/pattern/FormatInfo.java @@ -72,7 +72,8 @@ public static FormatInfo valueOf(String str) throws IllegalArgumentException { minPart = str; } - if (minPart != null && minPart.length() > 0) { + // minPart is never null here: both branches above assign it + if (minPart.length() > 0) { int min = Integer.parseInt(minPart); if (min >= 0) { fi.min = min; @@ -82,7 +83,8 @@ public static FormatInfo valueOf(String str) throws IllegalArgumentException { } } - if (maxPart != null && maxPart.length() > 0) { + // a non-null maxPart is never empty: a trailing '.' is rejected above + if (maxPart != null) { int max = Integer.parseInt(maxPart); if (max >= 0) { fi.max = max; diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/FormatInfoTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/FormatInfoTest.java index c5b172ca2..b843dff87 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/FormatInfoTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/FormatInfoTest.java @@ -15,8 +15,11 @@ */ package ch.qos.logback.core.pattern.parser; -import static junit.framework.Assert.assertEquals; -import static junit.framework.Assert.fail; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import org.junit.Test; @@ -119,4 +122,44 @@ public void testMaxOnly() { assertEquals(witness, fi); } } -} \ No newline at end of file + + @Test + public void emptyStringKeepsAllDefaults() { + FormatInfo fi = FormatInfo.valueOf(""); + assertEquals(new FormatInfo(), fi); + assertEquals(Integer.MIN_VALUE, fi.getMin()); + assertEquals(Integer.MAX_VALUE, fi.getMax()); + assertTrue(fi.isLeftPad()); + assertTrue(fi.isLeftTruncate()); + } + + @Test + public void emptyMinPartBeforeDotKeepsDefaultMinAndPadding() { + FormatInfo fi = FormatInfo.valueOf(".-7"); + assertEquals(Integer.MIN_VALUE, fi.getMin()); + assertTrue(fi.isLeftPad()); + assertEquals(7, fi.getMax()); + assertFalse(fi.isLeftTruncate()); + } + + @Test + public void withoutDotMaxKeepsDefaultAndTruncatesLeft() { + FormatInfo fi = FormatInfo.valueOf("-3"); + assertEquals(3, fi.getMin()); + assertFalse(fi.isLeftPad()); + assertEquals(Integer.MAX_VALUE, fi.getMax()); + assertTrue(fi.isLeftTruncate()); + } + + @Test + public void loneDotIsRejectedAsEndingInDot() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> FormatInfo.valueOf(".")); + assertEquals("Formatting string [.] should not end with '.'", e.getMessage()); + } + + @Test + public void numberEndingInDotIsRejectedWithMessage() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> FormatInfo.valueOf("-4.")); + assertEquals("Formatting string [-4.] should not end with '.'", e.getMessage()); + } +} From f7166873e7c0546b2ec2170ceb8ed1e3d28b438d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:55:14 +0000 Subject: [PATCH 69/79] test: cover core.pattern converters, layouts, colors and escaping Adds unit tests so that every line and branch of ch.qos.logback.core.pattern (and its color and util subpackages) is covered: - Converter/FormattingConverter: setNext/setFormattingInfo reject a second call and keep the first value; null conversions are padded to a positive minimum and otherwise append nothing; truncation and padding directions. - CompositeConverter: child chain output is transformed; toString with and without formatting info and children. - ConverterUtil: findTail on null/single/chained converters, starting and setting the context along the chain. - DynamicConverter: first option for null, empty and filled option lists; every add* method records the right status type, message, throwable and origin. - FormatInfo: null input, equals on each field, hashCode, toString. - PatternLayoutBase: effective converter map precedence (default < context registry < instance) including a null default map and no context, starting without a context, the deprecated setContextForConverters, toString and the pattern-as-header logic. - PatternLayoutEncoderBase: header flags, deprecated setter warning, and setLayout being unsupported. - ReplacingCompositeConverter: errors for missing/too few options, pass through when not started, regex replacement when started. - Color converters: each one's ANSI code and the wrapped output, plus the ANSIConstants values. - RegularEscapeUtil: every escape case, the illegal-escape message and basicEscape translations. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../core/pattern/CompositeConverterTest.java | 102 +++++++++ .../logback/core/pattern/ConverterTest.java | 55 +++++ .../core/pattern/ConverterUtilTest.java | 103 ++++++++++ .../core/pattern/DynamicConverterTest.java | 142 +++++++++++++ .../core/pattern/FormattingConverterTest.java | 117 +++++++++++ .../core/pattern/PatternLayoutBaseTest.java | 194 ++++++++++++++++++ .../pattern/PatternLayoutEncoderBaseTest.java | 102 +++++++++ .../ReplacingCompositeConverterTest.java | 107 ++++++++++ .../logback/core/pattern/SpacePadderTest.java | 17 ++ .../core/pattern/color/ANSIConstantsTest.java | 50 +++++ .../ForegroundCompositeConverterBaseTest.java | 83 ++++++++ .../core/pattern/parser/FormatInfoTest.java | 53 +++++ .../pattern/util/RegularEscapeUtilTest.java | 106 ++++++++++ 13 files changed, 1231 insertions(+) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/pattern/CompositeConverterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/pattern/ConverterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/pattern/ConverterUtilTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/pattern/DynamicConverterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/pattern/FormattingConverterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/pattern/PatternLayoutBaseTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/pattern/PatternLayoutEncoderBaseTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/pattern/ReplacingCompositeConverterTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/pattern/color/ANSIConstantsTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/pattern/color/ForegroundCompositeConverterBaseTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/pattern/util/RegularEscapeUtilTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/CompositeConverterTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/CompositeConverterTest.java new file mode 100644 index 000000000..881d28ae1 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/CompositeConverterTest.java @@ -0,0 +1,102 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +import org.junit.Test; + +public class CompositeConverterTest { + + /** Wraps the children's output in angle brackets. */ + static class BracketCompositeConverter extends CompositeConverter { + @Override + protected String transform(Object event, String in) { + return "<" + in + ">"; + } + } + + /** A child converter with a recognizable toString(). */ + static class NamedConverter extends Converter { + private final String name; + + NamedConverter(String name) { + this.name = name; + } + + @Override + public String convert(Object event) { + return name; + } + + @Override + public String toString() { + return "Named(" + name + ")"; + } + } + + @Test + public void childConverterIsNullUntilSet() { + BracketCompositeConverter cc = new BracketCompositeConverter(); + assertNull(cc.getChildConverter()); + Converter child = new NamedConverter("a"); + cc.setChildConverter(child); + assertSame(child, cc.getChildConverter()); + } + + @Test + public void convertTransformsTheOutputOfTheWholeChildChain() { + BracketCompositeConverter cc = new BracketCompositeConverter(); + Converter first = new NamedConverter("a"); + first.setNext(new NamedConverter("b")); + cc.setChildConverter(first); + assertEquals("", cc.convert(new Object())); + } + + @Test + public void convertWithoutChildrenTransformsTheEmptyString() { + assertEquals("<>", new BracketCompositeConverter().convert(new Object())); + } + + @Test + public void toStringWithoutFormattingInfoOrChildren() { + assertEquals("CompositeConverter<>", new BracketCompositeConverter().toString()); + } + + @Test + public void toStringShowsFormattingInfo() { + BracketCompositeConverter cc = new BracketCompositeConverter(); + cc.setFormattingInfo(new FormatInfo(1, 5, false, true)); + assertEquals("CompositeConverter", cc.toString()); + } + + @Test + public void toStringShowsChildren() { + BracketCompositeConverter cc = new BracketCompositeConverter(); + cc.setChildConverter(new NamedConverter("a")); + assertEquals("CompositeConverter<, children: Named(a)>", cc.toString()); + } + + @Test + public void toStringShowsFormattingInfoAndChildren() { + BracketCompositeConverter cc = new BracketCompositeConverter(); + cc.setFormattingInfo(new FormatInfo(2, 3, true, false)); + cc.setChildConverter(new NamedConverter("x")); + assertEquals("CompositeConverter", cc.toString()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/ConverterTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/ConverterTest.java new file mode 100644 index 000000000..f9ab97c65 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/ConverterTest.java @@ -0,0 +1,55 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +public class ConverterTest { + + @Test + public void writeAppendsTheConversion() { + StringBuilder buf = new StringBuilder("x"); + new LiteralConverter("abc").write(buf, new Object()); + assertEquals("xabc", buf.toString()); + } + + @Test + public void nextIsNullUntilSet() { + Converter first = new LiteralConverter("a"); + assertNull(first.getNext()); + + Converter second = new LiteralConverter("b"); + first.setNext(second); + assertSame(second, first.getNext()); + } + + @Test + public void settingNextTwiceIsRejectedAndKeepsTheFirstNext() { + Converter first = new LiteralConverter("a"); + Converter second = new LiteralConverter("b"); + Converter third = new LiteralConverter("c"); + first.setNext(second); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> first.setNext(third)); + assertEquals("Next converter has been already set", e.getMessage()); + assertSame(second, first.getNext()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/ConverterUtilTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/ConverterUtilTest.java new file mode 100644 index 000000000..27dd97a42 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/ConverterUtilTest.java @@ -0,0 +1,103 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.pattern; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; + +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; + +public class ConverterUtilTest { + + @Test + public void isInstantiable() { + // the implicit public constructor is part of the published API + assertNotNull(new ConverterUtil()); + } + + @Test + public void findTailOfNullChainIsNull() { + assertNull(ConverterUtil.findTail(null)); + } + + @Test + public void findTailOfSingleConverterIsThatConverter() { + Converter head = new LiteralConverter("a"); + assertSame(head, ConverterUtil.findTail(head)); + } + + @Test + public void findTailReturnsTheLastConverterOfTheChain() { + Converter head = new LiteralConverter("a"); + Converter middle = new LiteralConverter("b"); + Converter tail = new LiteralConverter("c"); + head.setNext(middle); + middle.setNext(tail); + assertSame(tail, ConverterUtil.findTail(head)); + } + + @Test + public void startConvertersStartsDynamicAndCompositeConvertersAndTheirChildren() { + ConverterHello dynamic = new ConverterHello(); + ReplacingCompositeConverter composite = new ReplacingCompositeConverter(); + composite.setOptionList(Arrays.asList("a", "b")); + Converter123 child = new Converter123(); + composite.setChildConverter(child); + Converter literal = new LiteralConverter("-"); + dynamic.setNext(literal); + literal.setNext(composite); + + ConverterUtil.startConverters(dynamic); + + assertTrue(dynamic.isStarted()); + assertTrue(composite.isStarted()); + assertTrue(child.isStarted()); + } + + @Test + public void startConvertersLeavesConvertersOutsideTheChainStopped() { + ConverterHello head = new ConverterHello(); + ConverterHello notLinked = new ConverterHello(); + + ConverterUtil.startConverters(head); + + assertTrue(head.isStarted()); + assertFalse(notLinked.isStarted()); + } + + @Test + public void setContextForConvertersSetsContextOnContextAwareConverters() { + Context context = new ContextBase(); + ConverterHello first = new ConverterHello(); + Converter literal = new LiteralConverter("-"); + Converter123 last = new Converter123(); + first.setNext(literal); + literal.setNext(last); + + ConverterUtil.setContextForConverters(context, first); + + assertSame(context, first.getContext()); + assertSame(context, last.getContext()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/DynamicConverterTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/DynamicConverterTest.java new file mode 100644 index 000000000..e1fafc0b8 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/DynamicConverterTest.java @@ -0,0 +1,142 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.status.ErrorStatus; +import ch.qos.logback.core.status.InfoStatus; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.WarnStatus; + +public class DynamicConverterTest { + + private final Context context = new ContextBase(); + private final ConverterHello converter = new ConverterHello(); + + @Before + public void setUp() { + converter.setContext(context); + } + + private Status onlyStatus() { + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(1, statuses.size()); + return statuses.get(0); + } + + private void assertOnlyStatus(Class type, int level, String msg, Throwable ex) { + Status s = onlyStatus(); + assertSame(type, s.getClass()); + assertEquals(level, s.getLevel()); + assertEquals(msg, s.getMessage()); + assertSame(ex, s.getThrowable()); + assertSame(converter, s.getOrigin()); + } + + @Test + public void getContextReturnsTheContextSet() { + assertSame(context, converter.getContext()); + } + + @Test + public void startAndStopToggleIsStarted() { + assertFalse(converter.isStarted()); + converter.start(); + assertTrue(converter.isStarted()); + converter.stop(); + assertFalse(converter.isStarted()); + } + + @Test + public void firstOptionIsNullWithoutOptionList() { + assertNull(converter.getFirstOption()); + assertNull(converter.getOptionList()); + } + + @Test + public void firstOptionIsNullForEmptyOptionList() { + converter.setOptionList(Collections.emptyList()); + assertNull(converter.getFirstOption()); + } + + @Test + public void firstOptionIsTheFirstElementOfTheOptionList() { + List options = Arrays.asList("first", "second"); + converter.setOptionList(options); + assertEquals("first", converter.getFirstOption()); + assertSame(options, converter.getOptionList()); + } + + @Test + public void addStatusForwardsTheStatusToTheContext() { + Status status = new InfoStatus("hello", this); + converter.addStatus(status); + assertSame(status, onlyStatus()); + } + + @Test + public void addInfoRecordsAnInfoStatus() { + converter.addInfo("info msg"); + assertOnlyStatus(InfoStatus.class, Status.INFO, "info msg", null); + } + + @Test + public void addInfoWithThrowableRecordsTheThrowable() { + Exception ex = new Exception("boom"); + converter.addInfo("info msg", ex); + assertOnlyStatus(InfoStatus.class, Status.INFO, "info msg", ex); + } + + @Test + public void addWarnRecordsAWarnStatus() { + converter.addWarn("warn msg"); + assertOnlyStatus(WarnStatus.class, Status.WARN, "warn msg", null); + } + + @Test + public void addWarnWithThrowableRecordsTheThrowable() { + Exception ex = new Exception("boom"); + converter.addWarn("warn msg", ex); + assertOnlyStatus(WarnStatus.class, Status.WARN, "warn msg", ex); + } + + @Test + public void addErrorRecordsAnErrorStatus() { + converter.addError("error msg"); + assertOnlyStatus(ErrorStatus.class, Status.ERROR, "error msg", null); + } + + @Test + public void addErrorWithThrowableRecordsTheThrowable() { + Exception ex = new Exception("boom"); + converter.addError("error msg", ex); + assertOnlyStatus(ErrorStatus.class, Status.ERROR, "error msg", ex); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/FormattingConverterTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/FormattingConverterTest.java new file mode 100644 index 000000000..6f486acad --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/FormattingConverterTest.java @@ -0,0 +1,117 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +public class FormattingConverterTest { + + /** Converts every event to a fixed (possibly null) string. */ + static class FixedFormattingConverter extends FormattingConverter { + private final String value; + + FixedFormattingConverter(String value) { + this.value = value; + } + + @Override + public String convert(Object event) { + return value; + } + } + + private static String write(String value, FormatInfo fi) { + FixedFormattingConverter c = new FixedFormattingConverter(value); + if (fi != null) { + c.setFormattingInfo(fi); + } + StringBuilder buf = new StringBuilder("["); + c.write(buf, new Object()); + return buf.append(']').toString(); + } + + @Test + public void formattingInfoIsNullUntilSet() { + FixedFormattingConverter c = new FixedFormattingConverter("x"); + assertNull(c.getFormattingInfo()); + FormatInfo fi = new FormatInfo(1, 2); + c.setFormattingInfo(fi); + assertSame(fi, c.getFormattingInfo()); + } + + @Test + public void settingFormattingInfoTwiceIsRejectedAndKeepsTheFirst() { + FixedFormattingConverter c = new FixedFormattingConverter("x"); + FormatInfo first = new FormatInfo(1, 2); + c.setFormattingInfo(first); + + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> c.setFormattingInfo(new FormatInfo(3, 4))); + assertEquals("FormattingInfo has been already set", e.getMessage()); + assertSame(first, c.getFormattingInfo()); + } + + @Test + public void withoutFormattingInfoConversionIsAppendedAsIs() { + assertEquals("[abc]", write("abc", null)); + assertEquals("[null]", write(null, null)); + } + + @Test + public void nullConversionIsPaddedToPositiveMinimum() { + assertEquals("[ ]", write(null, new FormatInfo(3, 10))); + } + + @Test + public void nullConversionWithZeroMinimumAppendsNothing() { + assertEquals("[]", write(null, new FormatInfo(0, 10))); + } + + @Test + public void nullConversionWithDefaultMinimumAppendsNothing() { + assertEquals("[]", write(null, new FormatInfo())); + } + + @Test + public void longConversionIsTruncatedFromTheLeftByDefault() { + assertEquals("[def]", write("abcdef", new FormatInfo(0, 3, true, true))); + } + + @Test + public void longConversionIsTruncatedFromTheRightWhenRequested() { + assertEquals("[abc]", write("abcdef", new FormatInfo(0, 3, true, false))); + } + + @Test + public void shortConversionIsLeftPadded() { + assertEquals("[ ab]", write("ab", new FormatInfo(4, 10, true, true))); + } + + @Test + public void shortConversionIsRightPaddedWhenRequested() { + assertEquals("[ab ]", write("ab", new FormatInfo(4, 10, false, true))); + } + + @Test + public void conversionWithinBoundsIsAppendedUnchanged() { + assertEquals("[abcd]", write("abcd", new FormatInfo(4, 4))); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/PatternLayoutBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/PatternLayoutBaseTest.java new file mode 100644 index 000000000..8c9071786 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/PatternLayoutBaseTest.java @@ -0,0 +1,194 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.CoreConstants; + +public class PatternLayoutBaseTest { + + /** A layout whose default converter map is supplied by the test. */ + static class MapPatternLayout extends PatternLayoutBase { + private final Map defaultMap; + + MapPatternLayout(Map defaultMap) { + this.defaultMap = defaultMap; + } + + @Override + public Map getDefaultConverterMap() { + return defaultMap; + } + + @Override + public String doLayout(Object event) { + return writeLoopOnConverters(event); + } + } + + /** A layout that prefixes the pattern header. */ + static class PrefixedPatternLayout extends MapPatternLayout { + PrefixedPatternLayout() { + super(new HashMap()); + } + + @Override + protected String getPresentationHeaderPrefix() { + return "#pattern: "; + } + } + + private static Map helloAndOttMap() { + Map map = new HashMap(); + map.put("hello", ConverterHello.class.getName()); + map.put("OTT", Converter123.class.getName()); + return map; + } + + @Test + public void effectiveConverterMapWithoutDefaultMapOrContextHoldsInstanceEntries() { + MapPatternLayout layout = new MapPatternLayout(null); + layout.getInstanceConverterMap().put("x", "some.Converter"); + + Map expected = new HashMap(); + expected.put("x", "some.Converter"); + assertEquals(expected, layout.getEffectiveConverterMap()); + } + + @Test + public void effectiveConverterMapLetsContextOverrideDefaultAndInstanceOverrideBoth() { + Map defaultMap = new HashMap(); + defaultMap.put("a", "default.A"); + defaultMap.put("b", "default.B"); + defaultMap.put("c", "default.C"); + MapPatternLayout layout = new MapPatternLayout(defaultMap); + + Context context = new ContextBase(); + Map registry = new HashMap(); + registry.put("b", "context.B"); + registry.put("c", "context.C"); + context.putObject(CoreConstants.PATTERN_RULE_REGISTRY, registry); + layout.setContext(context); + layout.getInstanceConverterMap().put("c", "instance.C"); + + Map expected = new HashMap(); + expected.put("a", "default.A"); + expected.put("b", "context.B"); + expected.put("c", "instance.C"); + assertEquals(expected, layout.getEffectiveConverterMap()); + } + + @Test + public void effectiveConverterMapIgnoresContextWithoutRegistry() { + MapPatternLayout layout = new MapPatternLayout(helloAndOttMap()); + layout.setContext(new ContextBase()); + assertEquals(helloAndOttMap(), layout.getEffectiveConverterMap()); + } + + @Test + public void startWithoutContextCompilesLiteralPatterns() { + MapPatternLayout layout = new MapPatternLayout(helloAndOttMap()); + layout.setPattern("just text"); + layout.start(); + + assertTrue(layout.isStarted()); + assertNull(layout.getContext()); + assertEquals("just text", layout.doLayout(new Object())); + } + + @Test + public void startWithContextCompilesAndStartsTheConverters() { + Context context = new ContextBase(); + MapPatternLayout layout = new MapPatternLayout(helloAndOttMap()); + layout.setContext(context); + layout.getInstanceConverterMap().put("EX", ExceptionalConverter.class.getName()); + layout.setPattern("%hello-%OTT%EX"); + layout.start(); + + assertTrue(layout.isStarted()); + assertEquals("Hello-123", layout.doLayout(new Object())); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } + + @Test + public void deprecatedSetContextForConvertersSetsTheLayoutContextOnTheChain() { + Context context = new ContextBase(); + MapPatternLayout layout = new MapPatternLayout(helloAndOttMap()); + layout.setContext(context); + ConverterHello first = new ConverterHello(); + Converter123 second = new Converter123(); + first.setNext(second); + + layout.setContextForConverters(first); + + assertSame(context, first.getContext()); + assertSame(context, second.getContext()); + } + + @Test + public void toStringShowsClassNameAndPattern() { + MapPatternLayout layout = new MapPatternLayout(helloAndOttMap()); + layout.setPattern("%hello %OTT"); + assertEquals(MapPatternLayout.class.getName() + "(\"%hello %OTT\")", layout.toString()); + } + + @Test + public void outputPatternAsHeaderIsOffByDefault() { + MapPatternLayout layout = new MapPatternLayout(helloAndOttMap()); + layout.setPattern("%hello"); + assertFalse(layout.isOutputPatternAsHeader()); + assertNull(layout.getPresentationHeader()); + } + + @Test + public void presentationHeaderFallsBackToTheConfiguredHeaderWhenPatternIsNotOutput() { + MapPatternLayout layout = new MapPatternLayout(helloAndOttMap()); + layout.setPattern("%hello"); + layout.setPresentationHeader("my header"); + assertEquals("my header", layout.getPresentationHeader()); + } + + @Test + public void presentationHeaderIsThePatternWhenOutputPatternAsHeaderIsSet() { + MapPatternLayout layout = new MapPatternLayout(helloAndOttMap()); + layout.setPattern("%hello %OTT"); + layout.setPresentationHeader("ignored"); + layout.setOutputPatternAsHeader(true); + + assertTrue(layout.isOutputPatternAsHeader()); + assertEquals("%hello %OTT", layout.getPresentationHeader()); + } + + @Test + public void presentationHeaderPrefixIsPrependedToThePattern() { + PrefixedPatternLayout layout = new PrefixedPatternLayout(); + layout.setPattern("%msg"); + layout.setOutputPatternAsHeader(true); + assertEquals("#pattern: %msg", layout.getPresentationHeader()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/PatternLayoutEncoderBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/PatternLayoutEncoderBaseTest.java new file mode 100644 index 000000000..f353fd309 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/PatternLayoutEncoderBaseTest.java @@ -0,0 +1,102 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.status.Status; + +public class PatternLayoutEncoderBaseTest { + + private final Context context = new ContextBase(); + private final PatternLayoutEncoderBase encoder = new PatternLayoutEncoderBase(); + + @Before + public void setUp() { + encoder.setContext(context); + } + + @Test + public void patternIsNullUntilSet() { + assertNull(encoder.getPattern()); + encoder.setPattern("%msg%n"); + assertEquals("%msg%n", encoder.getPattern()); + } + + @Test + public void outputPatternAsHeaderIsOffByDefault() { + assertFalse(encoder.isOutputPatternAsHeader()); + assertFalse(encoder.isOutputPatternAsPresentationHeader()); + } + + @Test + public void setOutputPatternAsHeaderTogglesBothGettersWithoutWarning() { + encoder.setOutputPatternAsHeader(true); + assertTrue(encoder.isOutputPatternAsHeader()); + assertTrue(encoder.isOutputPatternAsPresentationHeader()); + + encoder.setOutputPatternAsHeader(false); + assertFalse(encoder.isOutputPatternAsHeader()); + assertFalse(encoder.isOutputPatternAsPresentationHeader()); + + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } + + @Test + public void deprecatedSetterSetsTheFlagAndWarns() { + encoder.setOutputPatternAsPresentationHeader(true); + + assertTrue(encoder.isOutputPatternAsHeader()); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(1, statuses.size()); + assertEquals(Status.WARN, statuses.get(0).getLevel()); + assertEquals("[outputPatternAsPresentationHeader] property is deprecated. Please use [outputPatternAsHeader] option instead.", + statuses.get(0).getMessage()); + } + + @Test + public void layoutCannotBeSet() { + PatternLayoutBase layout = new PatternLayoutBase() { + @Override + public Map getDefaultConverterMap() { + return new HashMap(); + } + + @Override + public String doLayout(Object event) { + return ""; + } + }; + + UnsupportedOperationException e = assertThrows(UnsupportedOperationException.class, + () -> encoder.setLayout(layout)); + assertEquals("one cannot set the layout of " + PatternLayoutEncoderBase.class.getName(), e.getMessage()); + assertNull(encoder.getLayout()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/ReplacingCompositeConverterTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/ReplacingCompositeConverterTest.java new file mode 100644 index 000000000..0486bf230 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/ReplacingCompositeConverterTest.java @@ -0,0 +1,107 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.status.Status; + +public class ReplacingCompositeConverterTest { + + private final Context context = new ContextBase(); + private final ReplacingCompositeConverter converter = new ReplacingCompositeConverter(); + + @Before + public void setUp() { + converter.setContext(context); + converter.setChildConverter(new LiteralConverter("a1b22c")); + } + + private Status onlyStatus() { + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(1, statuses.size()); + return statuses.get(0); + } + + @Test + public void withoutOptionsItReportsAnErrorAndDoesNotStart() { + converter.start(); + + assertFalse(converter.isStarted()); + Status s = onlyStatus(); + assertEquals(Status.ERROR, s.getLevel()); + assertEquals("at least two options are expected whereas you have declared none", s.getMessage()); + } + + @Test + public void withEmptyOptionListItReportsAnErrorAndDoesNotStart() { + converter.setOptionList(Collections.emptyList()); + converter.start(); + + assertFalse(converter.isStarted()); + Status s = onlyStatus(); + assertEquals(Status.ERROR, s.getLevel()); + assertEquals("at least two options are expected whereas you have declared only 0as [[]]", s.getMessage()); + } + + @Test + public void withOneOptionItReportsAnErrorAndDoesNotStart() { + converter.setOptionList(Collections.singletonList("\\d")); + converter.start(); + + assertFalse(converter.isStarted()); + Status s = onlyStatus(); + assertEquals(Status.ERROR, s.getLevel()); + assertEquals("at least two options are expected whereas you have declared only 1as [[\\d]]", s.getMessage()); + } + + @Test + public void unstartedConverterLeavesTheChildOutputUnchanged() { + converter.setOptionList(Collections.singletonList("\\d")); + converter.start(); + + assertEquals("a1b22c", converter.convert(new Object())); + } + + @Test + public void startedConverterReplacesEveryMatchOfTheRegex() { + converter.setOptionList(Arrays.asList("\\d+", "#")); + converter.start(); + + assertTrue(converter.isStarted()); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + assertEquals("a#b#c", converter.convert(new Object())); + } + + @Test + public void replacementMayReferToCapturedGroups() { + converter.setOptionList(Arrays.asList("(\\d)", "<$1>")); + converter.start(); + + assertEquals("a<1>b<2><2>c", converter.convert(new Object())); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/SpacePadderTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/SpacePadderTest.java index 4faa0725f..f7923084c 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/pattern/SpacePadderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/SpacePadderTest.java @@ -16,6 +16,7 @@ package ch.qos.logback.core.pattern; import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNotNull; import org.junit.After; import org.junit.AfterClass; @@ -106,4 +107,20 @@ public void lengthyPad() { } + @Test + public void isInstantiable() { + // the implicit public constructor is part of the published API + assertNotNull(new SpacePadder()); + } + + @Test + public void spacePadAppendsExactlyTheRequestedNumberOfSpaces() { + for (int n = 0; n <= 70; n++) { + StringBuilder buf = new StringBuilder("x"); + SpacePadder.spacePad(buf, n); + assertEquals(n + 1, buf.length()); + assertEquals("x", buf.toString().trim()); + } + } + } diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/color/ANSIConstantsTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/color/ANSIConstantsTest.java new file mode 100644 index 000000000..653ed41b3 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/color/ANSIConstantsTest.java @@ -0,0 +1,50 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.pattern.color; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import org.junit.Test; + +public class ANSIConstantsTest { + + @Test + public void isInstantiable() { + // the implicit public constructor is part of the published API + assertNotNull(new ANSIConstants()); + } + + @Test + public void escapeSequenceDelimiters() { + assertEquals("\u001b[", ANSIConstants.ESC_START); + assertEquals("m", ANSIConstants.ESC_END); + assertEquals("1;", ANSIConstants.BOLD); + } + + @Test + public void foregroundColorCodesFollowTheAnsiStandard() { + assertEquals("30", ANSIConstants.BLACK_FG); + assertEquals("31", ANSIConstants.RED_FG); + assertEquals("32", ANSIConstants.GREEN_FG); + assertEquals("33", ANSIConstants.YELLOW_FG); + assertEquals("34", ANSIConstants.BLUE_FG); + assertEquals("35", ANSIConstants.MAGENTA_FG); + assertEquals("36", ANSIConstants.CYAN_FG); + assertEquals("37", ANSIConstants.WHITE_FG); + assertEquals("39", ANSIConstants.DEFAULT_FG); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/color/ForegroundCompositeConverterBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/color/ForegroundCompositeConverterBaseTest.java new file mode 100644 index 000000000..561271828 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/color/ForegroundCompositeConverterBaseTest.java @@ -0,0 +1,83 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.pattern.color; + +import static org.junit.Assert.assertEquals; + +import java.util.Arrays; +import java.util.Collection; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +import ch.qos.logback.core.pattern.LiteralConverter; + +/** + * Checks that every color composite converter wraps its children's output in + * the ANSI escape sequence of its color and resets to the default color after. + */ +@RunWith(Parameterized.class) +public class ForegroundCompositeConverterBaseTest { + + private static final String RESET = "\u001b[0;39m"; + + @Parameters(name = "{0}") + public static Collection converters() { + return Arrays.asList(new Object[][] { + { "black", BlackCompositeConverter.class, "30" }, + { "red", RedCompositeConverter.class, "31" }, + { "green", GreenCompositeConverter.class, "32" }, + { "yellow", YellowCompositeConverter.class, "33" }, + { "blue", BlueCompositeConverter.class, "34" }, + { "magenta", MagentaCompositeConverter.class, "35" }, + { "cyan", CyanCompositeConverter.class, "36" }, + { "white", WhiteCompositeConverter.class, "37" }, + { "gray", GrayCompositeConverter.class, "1;30" }, + { "boldRed", BoldRedCompositeConverter.class, "1;31" }, + { "boldGreen", BoldGreenCompositeConverter.class, "1;32" }, + { "boldYellow", BoldYellowCompositeConverter.class, "1;33" }, + { "boldBlue", BoldBlueCompositeConverter.class, "1;34" }, + { "boldMagenta", BoldMagentaCompositeConverter.class, "1;35" }, + { "boldCyan", BoldCyanCompositeConverter.class, "1;36" }, + { "boldWhite", BoldWhiteCompositeConverter.class, "1;37" }, + }); + } + + private final ForegroundCompositeConverterBase converter; + private final String colorCode; + + @SuppressWarnings("unchecked") + public ForegroundCompositeConverterBaseTest(String name, Class converterClass, String colorCode) + throws Exception { + // a fresh converter for every test + this.converter = (ForegroundCompositeConverterBase) converterClass.getConstructor().newInstance(); + this.colorCode = colorCode; + } + + @Test + public void foregroundColorCode() { + assertEquals(colorCode, converter.getForegroundColorCode(new Object())); + } + + @Test + public void wrapsChildOutputInColorAndResetSequences() { + converter.setChildConverter(new LiteralConverter("text")); + converter.start(); + assertEquals("\u001b[" + colorCode + "m" + "text" + RESET, converter.convert(new Object())); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/FormatInfoTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/FormatInfoTest.java index b843dff87..97127bad0 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/FormatInfoTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/parser/FormatInfoTest.java @@ -17,6 +17,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -162,4 +163,56 @@ public void numberEndingInDotIsRejectedWithMessage() { IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> FormatInfo.valueOf("-4.")); assertEquals("Formatting string [-4.] should not end with '.'", e.getMessage()); } + + @Test + public void nullStringIsRejected() { + NullPointerException e = assertThrows(NullPointerException.class, () -> FormatInfo.valueOf(null)); + assertEquals("Argument cannot be null", e.getMessage()); + } + + @Test + public void twoArgConstructorKeepsLeftPadAndLeftTruncate() { + FormatInfo fi = new FormatInfo(3, 9); + assertEquals(new FormatInfo(3, 9, true, true), fi); + } + + @Test + public void equalsIsReflexive() { + FormatInfo fi = new FormatInfo(1, 2, false, false); + assertTrue(fi.equals(fi)); + } + + @Test + public void equalsRejectsNullAndOtherTypes() { + FormatInfo fi = new FormatInfo(1, 2, true, true); + assertFalse(fi.equals(null)); + assertFalse(fi.equals("FormatInfo(1, 2, true, true)")); + } + + @Test + public void equalsComparesEveryField() { + FormatInfo fi = new FormatInfo(1, 2, true, true); + assertTrue(fi.equals(new FormatInfo(1, 2, true, true))); + assertFalse(fi.equals(new FormatInfo(9, 2, true, true))); + assertFalse(fi.equals(new FormatInfo(1, 9, true, true))); + assertFalse(fi.equals(new FormatInfo(1, 2, false, true))); + assertFalse(fi.equals(new FormatInfo(1, 2, true, false))); + } + + @Test + public void hashCodeCombinesEveryField() { + // ((min * 31 + max) * 31 + leftPad) * 31 + leftTruncate + assertEquals(31745, new FormatInfo(1, 2, true, true).hashCode()); + assertEquals(31744, new FormatInfo(1, 2, true, false).hashCode()); + assertEquals(31714, new FormatInfo(1, 2, false, true).hashCode()); + assertEquals(31713, new FormatInfo(1, 2, false, false).hashCode()); + assertEquals(new FormatInfo(4, 5).hashCode(), FormatInfo.valueOf("4.5").hashCode()); + assertNotEquals(new FormatInfo(4, 5).hashCode(), new FormatInfo(5, 4).hashCode()); + } + + @Test + public void toStringListsEveryField() { + assertEquals("FormatInfo(1, 2, true, false)", new FormatInfo(1, 2, true, false).toString()); + assertEquals("FormatInfo(3, 4, false, true)", FormatInfo.valueOf("-3.4").toString()); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/util/RegularEscapeUtilTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/util/RegularEscapeUtilTest.java new file mode 100644 index 000000000..0d4072fa1 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/util/RegularEscapeUtilTest.java @@ -0,0 +1,106 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.pattern.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +public class RegularEscapeUtilTest { + + private final RegularEscapeUtil util = new RegularEscapeUtil(); + + private String escape(String escapeChars, char next) { + StringBuffer buf = new StringBuffer("["); + util.escape(escapeChars, buf, next, 0); + return buf.append(']').toString(); + } + + @Test + public void escapeCharsAreKeptLiterally() { + assertEquals("[%]", escape("%)", '%')); + assertEquals("[)]", escape("%)", ')')); + } + + @Test + public void escapedUnderscoreIsSwallowed() { + assertEquals("[]", escape("%", '_')); + } + + @Test + public void escapedBackslashIsKept() { + assertEquals("[\\]", escape("%", '\\')); + } + + @Test + public void escapedControlLettersBecomeControlCharacters() { + assertEquals("[\t]", escape("%", 't')); + assertEquals("[\r]", escape("%", 'r')); + assertEquals("[\n]", escape("%", 'n')); + } + + @Test + public void escapeCharsTakePrecedenceOverControlLetters() { + assertEquals("[n]", escape("n", 'n')); + } + + @Test + public void illegalEscapeIsRejectedWithTheAllowedEscapesListed() { + StringBuffer buf = new StringBuffer(); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> util.escape("%)", buf, 'x', 7)); + assertEquals("Illegal char 'x at column 7. Only \\\\, \\_, \\%, \\), \\t, \\n, \\r combinations" + + " are allowed as escape characters.", e.getMessage()); + assertEquals("", buf.toString()); + } + + @Test + public void illegalEscapeWithoutEscapeCharsListsOnlyTheBuiltInEscapes() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> util.escape("", new StringBuffer(), 'q', 3)); + assertEquals("Illegal char 'q at column 3. Only \\\\, \\_, \\t, \\n, \\r combinations" + + " are allowed as escape characters.", e.getMessage()); + } + + @Test + public void formatEscapeCharsForListingPrefixesEachCharWithCommaAndBackslash() { + assertEquals("", util.formatEscapeCharsForListing("")); + assertEquals(", \\%", util.formatEscapeCharsForListing("%")); + assertEquals(", \\%, \\), \\}", util.formatEscapeCharsForListing("%)}")); + } + + @Test + public void basicEscapeLeavesUnescapedTextUnchanged() { + assertEquals("", RegularEscapeUtil.basicEscape("")); + assertEquals("plain text", RegularEscapeUtil.basicEscape("plain text")); + } + + @Test + public void basicEscapeTranslatesControlEscapes() { + assertEquals("a\nb", RegularEscapeUtil.basicEscape("a\\nb")); + assertEquals("a\rb", RegularEscapeUtil.basicEscape("a\\rb")); + assertEquals("a\tb", RegularEscapeUtil.basicEscape("a\\tb")); + assertEquals("a\fb", RegularEscapeUtil.basicEscape("a\\fb")); + } + + @Test + public void basicEscapeDropsTheBackslashBeforeOtherChars() { + assertEquals("a.b", RegularEscapeUtil.basicEscape("a\\.b")); + assertEquals("a\\b", RegularEscapeUtil.basicEscape("a\\\\b")); + assertEquals("xy", RegularEscapeUtil.basicEscape("\\x\\y")); + } +} From e3efbc8f13a0904e0ca921164bd53fb9285f182c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:35:02 +0000 Subject: [PATCH 70/79] test: pin core.pattern padding bounds, parse errors and post-compile hook Kill mutants the earlier tests let through: FormattingConverter's `0 < min` guard (a negative or a minimum of one with a null conversion), the ScanException branch of PatternLayoutBase.start and the post-compile processor call. Check each padded char in SpacePadderTest instead of trimming. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../core/pattern/FormattingConverterTest.java | 11 +++++ .../core/pattern/PatternLayoutBaseTest.java | 44 +++++++++++++++++++ .../logback/core/pattern/SpacePadderTest.java | 5 ++- 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/FormattingConverterTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/FormattingConverterTest.java index 6f486acad..f62c36c2a 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/pattern/FormattingConverterTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/FormattingConverterTest.java @@ -80,6 +80,11 @@ public void nullConversionIsPaddedToPositiveMinimum() { assertEquals("[ ]", write(null, new FormatInfo(3, 10))); } + @Test + public void nullConversionIsPaddedToAMinimumOfOne() { + assertEquals("[ ]", write(null, new FormatInfo(1, 10))); + } + @Test public void nullConversionWithZeroMinimumAppendsNothing() { assertEquals("[]", write(null, new FormatInfo(0, 10))); @@ -90,6 +95,12 @@ public void nullConversionWithDefaultMinimumAppendsNothing() { assertEquals("[]", write(null, new FormatInfo())); } + @Test + public void nullConversionWithNegativeMinimumAppendsNothing() { + // SpacePadder.spacePad(buf, -5) would append 27 spaces (the low five bits of -5) + assertEquals("[]", write(null, new FormatInfo(-5, 10))); + } + @Test public void longConversionIsTruncatedFromTheLeftByDefault() { assertEquals("[def]", write("abcdef", new FormatInfo(0, 3, true, true))); diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/PatternLayoutBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/PatternLayoutBaseTest.java index 8c9071786..4c21b85b2 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/pattern/PatternLayoutBaseTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/PatternLayoutBaseTest.java @@ -21,7 +21,10 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.junit.Test; @@ -29,6 +32,8 @@ import ch.qos.logback.core.Context; import ch.qos.logback.core.ContextBase; import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.spi.ScanException; +import ch.qos.logback.core.status.Status; public class PatternLayoutBaseTest { @@ -135,6 +140,45 @@ public void startWithContextCompilesAndStartsTheConverters() { assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); } + @Test + public void startReportsAnUnparsablePatternAndStaysStopped() { + Context context = new ContextBase(); + MapPatternLayout layout = new MapPatternLayout(helloAndOttMap()); + layout.setContext(context); + layout.setPattern("%hello%(abc"); + layout.start(); + + assertFalse(layout.isStarted()); + assertEquals("", layout.doLayout(new Object())); + List statuses = context.getStatusManager().getCopyOfStatusList(); + Status last = statuses.get(statuses.size() - 1); + assertEquals(Status.ERROR, last.getLevel()); + assertEquals("Failed to parse pattern \"%hello%(abc\".", last.getMessage()); + assertSame(layout, last.getOrigin()); + assertTrue(last.getThrowable() instanceof ScanException); + } + + @Test + public void startRunsThePostCompileProcessorOnTheCompiledChain() { + final Context context = new ContextBase(); + final List seenContexts = new ArrayList(); + MapPatternLayout layout = new MapPatternLayout(helloAndOttMap()); + layout.setContext(context); + layout.setPattern("%hello"); + layout.setPostCompileProcessor(new PostCompileProcessor() { + @Override + public void process(Context ctx, Converter head) { + seenContexts.add(ctx); + ConverterUtil.findTail(head).setNext(new LiteralConverter("!")); + } + }); + layout.start(); + + assertTrue(layout.isStarted()); + assertEquals(Collections.singletonList(context), seenContexts); + assertEquals("Hello!", layout.doLayout(new Object())); + } + @Test public void deprecatedSetContextForConvertersSetsTheLayoutContextOnTheChain() { Context context = new ContextBase(); diff --git a/logback-android/src/test/java/ch/qos/logback/core/pattern/SpacePadderTest.java b/logback-android/src/test/java/ch/qos/logback/core/pattern/SpacePadderTest.java index f7923084c..443a63716 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/pattern/SpacePadderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/pattern/SpacePadderTest.java @@ -119,7 +119,10 @@ public void spacePadAppendsExactlyTheRequestedNumberOfSpaces() { StringBuilder buf = new StringBuilder("x"); SpacePadder.spacePad(buf, n); assertEquals(n + 1, buf.length()); - assertEquals("x", buf.toString().trim()); + assertEquals('x', buf.charAt(0)); + for (int i = 1; i <= n; i++) { + assertEquals(' ', buf.charAt(i)); + } } } From 3711bc400e4bedacc4d38be6d2d8ce601a749c7d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:21:45 +0000 Subject: [PATCH 71/79] test: cover core.spi, core.sift and classic.sift to 100% Pin the remaining behavior of the sifting and component-tracking code: - AbstractComponentTracker: stale components are removed before their timeout, allComponents() returns live and lingering components, the timeout/max getters, and the internal entry's equals/hashCode/toString. - AppenderAttachableImpl: null arguments are rejected or ignored, attachment is by identity, unknown names detach nothing. - ContextAwareBase/ContextAwareImpl: context re-set rules, every status flavor with its origin, the one-time "No context given" console warning and a context without status manager. - CyclicBufferTracker buffer size, FilterAttachableImpl chain decisions. - AppenderTracker: a failing or null factory yields a started NOPAppender and the NOPAppender errors are capped at MAX_ERROR_COUNT; stopped appenders are removed before they time out. - SiftingAppenderBase: settings reach the tracker, stop() stops live and lingering nested appenders, append() is a no-op when not started. - SiftingJoranConfiguratorBase one-and-only-one check and its error cap, AbstractAppenderFactoryUsingJoran drops the enclosing element, discriminator lifecycle/keys/default values, SiftAction hand-over of the recorded events, SiftingAppender end-of-life marker detection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../sift/ContextBasedDiscriminatorTest.java | 78 ++++++ .../sift/MDCBasedDiscriminatorTest.java | 62 +++++ .../logback/classic/sift/SiftActionTest.java | 124 ++++++++++ .../sift/SiftingAppenderEndOfLifeTest.java | 158 ++++++++++++ ...AbstractAppenderFactoryUsingJoranTest.java | 67 +++++ .../core/sift/AbstractDiscriminatorTest.java | 50 ++++ .../core/sift/AppenderTrackerTest.java | 68 +++++ .../core/sift/DefaultDiscriminatorTest.java | 38 +++ .../core/sift/SiftingAppenderBaseTest.java | 168 +++++++++++++ .../SiftingJoranConfiguratorBaseTest.java | 119 +++++++++ .../spi/AbstractComponentTrackerTest.java | 232 ++++++++++++++++++ .../core/spi/AppenderAttachableImplTest.java | 67 +++++ .../core/spi/ContextAwareBaseTest.java | 156 ++++++++++++ .../core/spi/ContextAwareImplTest.java | 152 ++++++++++++ .../core/spi/CyclicBufferTrackerTest.java | 19 ++ .../core/spi/FilterAttachableImplTest.java | 125 ++++++++++ 16 files changed, 1683 insertions(+) create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/sift/ContextBasedDiscriminatorTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/sift/SiftActionTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/classic/sift/SiftingAppenderEndOfLifeTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/sift/AbstractAppenderFactoryUsingJoranTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/sift/AbstractDiscriminatorTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/sift/DefaultDiscriminatorTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/sift/SiftingJoranConfiguratorBaseTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/spi/AbstractComponentTrackerTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/spi/ContextAwareBaseTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/spi/ContextAwareImplTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/spi/FilterAttachableImplTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/classic/sift/ContextBasedDiscriminatorTest.java b/logback-android/src/test/java/ch/qos/logback/classic/sift/ContextBasedDiscriminatorTest.java new file mode 100644 index 000000000..3134f7309 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/sift/ContextBasedDiscriminatorTest.java @@ -0,0 +1,78 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.sift; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +import java.util.Collections; + +import org.junit.Test; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.LoggerContextVO; +import ch.qos.logback.classic.spi.LoggingEvent; + +public class ContextBasedDiscriminatorTest { + + static final String DEFAULT_VAL = "DEFAULT_VAL"; + + ContextBasedDiscriminator discriminator = new ContextBasedDiscriminator(); + LoggerContext context = new LoggerContext(); + Logger logger = context.getLogger(this.getClass()); + + @Test + public void discriminatingValueIsTheNameOfTheEventContext() { + context.setName("contextBasedDiscriminatorTest"); + discriminator.setDefaultValue(DEFAULT_VAL); + LoggingEvent event = new LoggingEvent("a", logger, Level.DEBUG, "", null, null); + + assertEquals("contextBasedDiscriminatorTest", discriminator.getDiscriminatingValue(event)); + } + + @Test + public void discriminatingValueIsTheDefaultValueWhenTheContextHasNoName() { + discriminator.setDefaultValue(DEFAULT_VAL); + LoggingEvent event = new LoggingEvent("a", logger, Level.DEBUG, "", null, null); + event.setLoggerContextRemoteView( + new LoggerContextVO(null, Collections.emptyMap(), 0L)); + + assertEquals(DEFAULT_VAL, discriminator.getDiscriminatingValue(event)); + } + + @Test + public void defaultValueIsNullUnlessSet() { + assertNull(discriminator.getDefaultValue()); + + discriminator.setDefaultValue(DEFAULT_VAL); + + assertEquals(DEFAULT_VAL, discriminator.getDefaultValue()); + } + + @Test + public void keyIsFixedToContextName() { + assertEquals("contextName", discriminator.getKey()); + + UnsupportedOperationException e = assertThrows(UnsupportedOperationException.class, + () -> discriminator.setKey("other")); + + assertEquals("Key cannot be set. Using fixed key contextName", e.getMessage()); + assertEquals("contextName", discriminator.getKey()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/sift/MDCBasedDiscriminatorTest.java b/logback-android/src/test/java/ch/qos/logback/classic/sift/MDCBasedDiscriminatorTest.java index 9c8e58d77..b1bf4e3af 100755 --- a/logback-android/src/test/java/ch/qos/logback/classic/sift/MDCBasedDiscriminatorTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/sift/MDCBasedDiscriminatorTest.java @@ -19,7 +19,9 @@ import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.core.status.Status; import ch.qos.logback.core.testUtil.RandomUtil; import org.junit.After; import org.junit.Before; @@ -27,8 +29,13 @@ import org.slf4j.MDC; +import java.util.List; + import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** * @author Ceki Gücü @@ -77,4 +84,59 @@ public void nullMDC() { String discriminatorValue = discriminator.getDiscriminatingValue(event); assertEquals(DEFAULT_VAL, discriminatorValue); } + + // LBCLASSIC-213: events, e.g. deserialized ones, may carry no MDC map at all + @Test + public void eventWithoutMDCMapYieldsTheDefaultValue() { + ILoggingEvent eventWithoutMDCMap = mock(ILoggingEvent.class); + when(eventWithoutMDCMap.getMDCPropertyMap()).thenReturn(null); + + assertEquals(DEFAULT_VAL, discriminator.getDiscriminatingValue(eventWithoutMDCMap)); + } + + @Test + public void defaultValueIsExposed() { + assertEquals(DEFAULT_VAL, discriminator.getDefaultValue()); + } + + @Test + public void startFailsWithoutKey() { + MDCBasedDiscriminator withoutKey = new MDCBasedDiscriminator(); + withoutKey.setContext(context); + withoutKey.setDefaultValue(DEFAULT_VAL); + + withoutKey.start(); + + assertFalse(withoutKey.isStarted()); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(1, statuses.size()); + assertEquals(Status.ERROR, statuses.get(0).getLevel()); + assertEquals("The \"Key\" property must be set", statuses.get(0).getMessage()); + } + + @Test + public void startFailsWithoutDefaultValue() { + MDCBasedDiscriminator withoutDefaultValue = new MDCBasedDiscriminator(); + withoutDefaultValue.setContext(context); + withoutDefaultValue.setKey(key); + + withoutDefaultValue.start(); + + assertFalse(withoutDefaultValue.isStarted()); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(1, statuses.size()); + assertEquals(Status.ERROR, statuses.get(0).getLevel()); + assertEquals("The \"DefaultValue\" property must be set", statuses.get(0).getMessage()); + } + + @Test + public void startReportsBothMissingProperties() { + MDCBasedDiscriminator unconfigured = new MDCBasedDiscriminator(); + unconfigured.setContext(context); + + unconfigured.start(); + + assertFalse(unconfigured.isStarted()); + assertEquals(2, context.getStatusManager().getCount()); + } } diff --git a/logback-android/src/test/java/ch/qos/logback/classic/sift/SiftActionTest.java b/logback-android/src/test/java/ch/qos/logback/classic/sift/SiftActionTest.java new file mode 100644 index 000000000..7a39bb4fe --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/sift/SiftActionTest.java @@ -0,0 +1,124 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.sift; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +import java.util.Arrays; + +import org.junit.Before; +import org.junit.Test; +import org.xml.sax.Attributes; +import org.xml.sax.helpers.AttributesImpl; + +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.joran.event.SaxEvent; +import ch.qos.logback.core.joran.spi.ActionException; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.sift.AppenderFactory; + +public class SiftActionTest { + + LoggerContext context = new LoggerContext(); + InterpretationContext ic = new InterpretationContext(context, null); + SiftAction action = new SiftAction(); + Attributes attributes = new AttributesImpl(); + + SaxEvent siftStart = mock(SaxEvent.class); + SaxEvent appenderStart = mock(SaxEvent.class); + SaxEvent appenderEnd = mock(SaxEvent.class); + SaxEvent siftEnd = mock(SaxEvent.class); + + @Before + public void setUp() { + action.setContext(context); + } + + @Test + public void beginRegistersTheActionAsInPlayListenerWithAnEmptyEventList() throws ActionException { + assertNull(action.getSeList()); + + action.begin(ic, "sift", attributes); + + assertFalse(ic.isListenerListEmpty()); + assertTrue(action.getSeList().isEmpty()); + } + + @Test + public void eventsInPlayAreRecordedInOrder() throws ActionException { + action.begin(ic, "sift", attributes); + + action.inPlay(siftStart); + action.inPlay(appenderStart); + + assertEquals(Arrays.asList(siftStart, appenderStart), action.getSeList()); + } + + @Test + public void endHandsTheRecordedEventsToTheSiftingAppenderOnTopOfTheStack() throws ActionException { + FactoryRecordingSiftingAppender sa = new FactoryRecordingSiftingAppender(); + MDCBasedDiscriminator discriminator = new MDCBasedDiscriminator(); + discriminator.setKey("userid"); + sa.setDiscriminator(discriminator); + ic.pushObject(sa); + + action.begin(ic, "sift", attributes); + for (SaxEvent e : Arrays.asList(siftStart, appenderStart, appenderEnd, siftEnd)) { + action.inPlay(e); + } + action.end(ic, "sift"); + + assertTrue(ic.isListenerListEmpty()); + assertSame(sa, ic.peekObject()); + assertTrue(sa.receivedFactory instanceof AppenderFactoryUsingJoran); + AppenderFactoryUsingJoran factory = (AppenderFactoryUsingJoran) sa.receivedFactory; + // the enclosing element is dropped + assertEquals(Arrays.asList(appenderStart, appenderEnd), factory.getEventList()); + // the discriminator key is handed over + assertEquals(SiftingJoranConfigurator.class.getName() + "{userid=alice}", + factory.getSiftingJoranConfigurator("alice").toString()); + } + + @Test + public void endIgnoresAnObjectOtherThanASiftingAppenderOnTopOfTheStack() throws ActionException { + Object notASiftingAppender = new Object(); + ic.pushObject(notASiftingAppender); + + action.begin(ic, "sift", attributes); + action.inPlay(siftStart); + action.end(ic, "sift"); + + assertTrue(ic.isListenerListEmpty()); + assertSame(notASiftingAppender, ic.peekObject()); + assertEquals(Arrays.asList(siftStart), action.getSeList()); + } + + static class FactoryRecordingSiftingAppender extends SiftingAppender { + AppenderFactory receivedFactory; + + @Override + public void setAppenderFactory(AppenderFactory appenderFactory) { + this.receivedFactory = appenderFactory; + super.setAppenderFactory(appenderFactory); + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/sift/SiftingAppenderEndOfLifeTest.java b/logback-android/src/test/java/ch/qos/logback/classic/sift/SiftingAppenderEndOfLifeTest.java new file mode 100644 index 000000000..f33b98ed3 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/classic/sift/SiftingAppenderEndOfLifeTest.java @@ -0,0 +1,158 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.sift; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; + +import org.junit.Before; +import org.junit.Test; +import org.slf4j.IMarkerFactory; +import org.slf4j.Marker; +import org.slf4j.helpers.BasicMarkerFactory; + +import ch.qos.logback.classic.ClassicConstants; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.Context; +import ch.qos.logback.core.read.ListAppender; +import ch.qos.logback.core.sift.AbstractDiscriminator; +import ch.qos.logback.core.sift.AppenderFactory; +import ch.qos.logback.core.sift.AppenderTracker; + +/** + * Plain JVM tests of how {@link SiftingAppender} decides that an event ends the + * life of its nested appender (see {@link ClassicConstants#FINALIZE_SESSION_MARKER}). + * The Joran-based tests of SiftingAppender are in SiftingAppenderTest. + */ +public class SiftingAppenderEndOfLifeTest { + + IMarkerFactory markerFactory = new BasicMarkerFactory(); + Marker unrelated = markerFactory.getDetachedMarker("UNRELATED"); + Marker otherUnrelated = markerFactory.getDetachedMarker("OTHER_UNRELATED"); + + LoggerContext loggerContext = new LoggerContext(); + Logger logger = loggerContext.getLogger(this.getClass()); + SiftingAppender sa = new SiftingAppender(); + long now = 3000; + + @Before + public void setUp() { + MessageDiscriminator discriminator = new MessageDiscriminator(); + discriminator.start(); + sa.setContext(loggerContext); + sa.setDiscriminator(discriminator); + sa.setAppenderFactory(new ListAppenderFactory()); + sa.start(); + assertTrue(sa.isStarted()); + } + + @Test + public void eventWithoutMarkersDoesNotEndLife() { + assertFalse(sa.eventMarksEndOfLife(event("a", now))); + } + + @Test + public void eventWithOnlyUnrelatedMarkersDoesNotEndLife() { + assertFalse(sa.eventMarksEndOfLife(event("a", now, unrelated, otherUnrelated))); + } + + @Test + public void finalizeSessionMarkerAfterUnrelatedMarkersEndsLife() { + assertTrue(sa.eventMarksEndOfLife( + event("a", now, unrelated, ClassicConstants.FINALIZE_SESSION_MARKER))); + } + + @Test + public void markerReferencingFinalizeSessionMarkerEndsLife() { + Marker parent = markerFactory.getDetachedMarker("PARENT"); + parent.add(ClassicConstants.FINALIZE_SESSION_MARKER); + + assertTrue(sa.eventMarksEndOfLife(event("a", now, parent))); + } + + @Test + public void timestampOfTheEventIsUsed() { + assertEquals(12345L, sa.getTimestamp(event("a", 12345L))); + } + + @Test + public void nestedAppenderOfAnEventWithUnrelatedMarkersOutlivesTheLingeringTimeout() { + sa.doAppend(event("a", now, unrelated)); + Appender nested = sa.getAppenderTracker().find("a"); + + // appending another event triggers the removal of stale nested appenders + sa.doAppend(event("b", now + AppenderTracker.LINGERING_TIMEOUT + 1)); + + assertSame(nested, sa.getAppenderTracker().find("a")); + assertTrue(nested.isStarted()); + assertEquals(1, ((ListAppender) nested).list.size()); + } + + @Test + public void nestedAppenderOfAFinalizingEventIsStoppedAfterTheLingeringTimeout() { + sa.doAppend(event("a", now, unrelated, ClassicConstants.FINALIZE_SESSION_MARKER)); + Appender nested = sa.getAppenderTracker().find("a"); + + sa.doAppend(event("b", now + AppenderTracker.LINGERING_TIMEOUT + 1)); + + assertNull(sa.getAppenderTracker().find("a")); + assertFalse(nested.isStarted()); + assertEquals(1, ((ListAppender) nested).list.size()); + } + + private LoggingEvent event(String message, long timestamp, Marker... markers) { + LoggingEvent event = new LoggingEvent("fqcn", logger, Level.INFO, message, null, null); + event.setTimeStamp(timestamp); + if (markers.length > 0) { + event.setMarkers(Arrays.asList(markers)); + } + return event; + } + + /** Sifts events by their message. */ + static class MessageDiscriminator extends AbstractDiscriminator { + @Override + public String getDiscriminatingValue(ILoggingEvent event) { + return event.getMessage(); + } + + @Override + public String getKey() { + return "message"; + } + } + + static class ListAppenderFactory implements AppenderFactory { + @Override + public Appender buildAppender(Context context, String discriminatingValue) { + ListAppender la = new ListAppender(); + la.setContext(context); + la.setName(discriminatingValue); + la.start(); + return la; + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/sift/AbstractAppenderFactoryUsingJoranTest.java b/logback-android/src/test/java/ch/qos/logback/core/sift/AbstractAppenderFactoryUsingJoranTest.java new file mode 100644 index 000000000..99c0a2c40 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/sift/AbstractAppenderFactoryUsingJoranTest.java @@ -0,0 +1,67 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.sift; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +import ch.qos.logback.core.joran.event.SaxEvent; + +public class AbstractAppenderFactoryUsingJoranTest { + + SaxEvent siftStart = mock(SaxEvent.class); + SaxEvent appenderStart = mock(SaxEvent.class); + SaxEvent appenderEnd = mock(SaxEvent.class); + SaxEvent siftEnd = mock(SaxEvent.class); + + @Test + public void eventListExcludesTheEnclosingSiftElement() { + Map parentProperties = Collections.singletonMap("p", "v"); + TestAppenderFactory factory = new TestAppenderFactory( + Arrays.asList(siftStart, appenderStart, appenderEnd, siftEnd), "userid", parentProperties); + + assertEquals(Arrays.asList(appenderStart, appenderEnd), factory.getEventList()); + assertEquals("userid", factory.key); + assertSame(parentProperties, factory.parentPropertyMap); + } + + @Test + public void eventListOfAnEmptySiftElementIsEmpty() { + TestAppenderFactory factory = new TestAppenderFactory( + Arrays.asList(siftStart, siftEnd), "userid", Collections.emptyMap()); + + assertEquals(Collections.emptyList(), factory.getEventList()); + } + + static class TestAppenderFactory extends AbstractAppenderFactoryUsingJoran { + TestAppenderFactory(List eventList, String key, Map parentPropertyMap) { + super(eventList, key, parentPropertyMap); + } + + @Override + public SiftingJoranConfiguratorBase getSiftingJoranConfigurator(String k) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/sift/AbstractDiscriminatorTest.java b/logback-android/src/test/java/ch/qos/logback/core/sift/AbstractDiscriminatorTest.java new file mode 100644 index 000000000..c1ec2bf81 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/sift/AbstractDiscriminatorTest.java @@ -0,0 +1,50 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.sift; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class AbstractDiscriminatorTest { + + AbstractDiscriminator discriminator = new AbstractDiscriminator() { + @Override + public String getDiscriminatingValue(Object e) { + return "value"; + } + + @Override + public String getKey() { + return "key"; + } + }; + + @Test + public void isNotStartedInitially() { + assertFalse(discriminator.isStarted()); + } + + @Test + public void startThenStop() { + discriminator.start(); + assertTrue(discriminator.isStarted()); + + discriminator.stop(); + assertFalse(discriminator.isStarted()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/sift/AppenderTrackerTest.java b/logback-android/src/test/java/ch/qos/logback/core/sift/AppenderTrackerTest.java index 22add95d2..6669d82bd 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/sift/AppenderTrackerTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/sift/AppenderTrackerTest.java @@ -26,8 +26,11 @@ import ch.qos.logback.core.Appender; import ch.qos.logback.core.Context; import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.helpers.NOPAppender; import ch.qos.logback.core.joran.spi.JoranException; import ch.qos.logback.core.read.ListAppender; +import ch.qos.logback.core.status.Status; import ch.qos.logback.core.testUtil.RandomUtil; import org.junit.Before; import org.junit.Test; @@ -149,7 +152,72 @@ public void trackerShouldHonorTimeoutParameter() { } } + @Test + public void stoppedAppenderIsRemovedBeforeItTimesOut() { + Appender stopped = appenderTracker.getOrCreate(key + "-stopped", now); + Appender running = appenderTracker.getOrCreate(key + "-running", now); + stopped.stop(); + + // well before DEFAULT_TIMEOUT + appenderTracker.removeStaleComponents(now); + + assertNull(appenderTracker.find(key + "-stopped")); + assertSame(running, appenderTracker.find(key + "-running")); + assertTrue(running.isStarted()); + } + + @Test + public void failureToBuildAnAppenderIsReportedAndYieldsAStartedNOPAppender() { + AppenderTracker tracker = new AppenderTracker(context, new FailingAppenderFactory()); + + Appender appender = tracker.getOrCreate(key, now); + + assertTrue(appender instanceof NOPAppender); + assertTrue(appender.isStarted()); + assertSame(context, appender.getContext()); + assertSame(appender, tracker.find(key)); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(2, statuses.size()); + assertEquals(Status.ERROR, statuses.get(0).getLevel()); + assertEquals("Error while building appender with discriminating value [" + key + "]", + statuses.get(0).getMessage()); + assertSame(tracker, statuses.get(0).getOrigin()); + assertEquals(Status.ERROR, statuses.get(1).getLevel()); + assertEquals("Building NOPAppender for discriminating value [" + key + "]", + statuses.get(1).getMessage()); + } + + @Test + public void nopAppenderErrorsAreReportedAtMostMaxErrorCountTimes() { + AppenderTracker tracker = new AppenderTracker(context, new NullAppenderFactory()); + + int attempts = CoreConstants.MAX_ERROR_COUNT + 3; + for (int i = 0; i < attempts; i++) { + assertTrue(tracker.getOrCreate(key + "-" + i, now) instanceof NOPAppender); + } + + assertEquals(attempts, tracker.getComponentCount()); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(CoreConstants.MAX_ERROR_COUNT, statuses.size()); + for (int i = 0; i < CoreConstants.MAX_ERROR_COUNT; i++) { + assertEquals("Building NOPAppender for discriminating value [" + key + "-" + i + "]", + statuses.get(i).getMessage()); + } + } + // ====================================================================== + static class FailingAppenderFactory implements AppenderFactory { + public Appender buildAppender(Context context, String discriminatingValue) throws JoranException { + throw new JoranException("cannot build " + discriminatingValue); + } + } + + static class NullAppenderFactory implements AppenderFactory { + public Appender buildAppender(Context context, String discriminatingValue) { + return null; + } + } + static class ListAppenderFactory implements AppenderFactory { public Appender buildAppender(Context context, String discriminatingValue) throws JoranException { diff --git a/logback-android/src/test/java/ch/qos/logback/core/sift/DefaultDiscriminatorTest.java b/logback-android/src/test/java/ch/qos/logback/core/sift/DefaultDiscriminatorTest.java new file mode 100644 index 000000000..62edc153d --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/sift/DefaultDiscriminatorTest.java @@ -0,0 +1,38 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.sift; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class DefaultDiscriminatorTest { + + DefaultDiscriminator discriminator = new DefaultDiscriminator(); + + @Test + public void keyIsDefault() { + assertEquals("default", DefaultDiscriminator.DEFAULT); + assertEquals(DefaultDiscriminator.DEFAULT, discriminator.getKey()); + } + + @Test + public void everyEventIsDiscriminatedAsDefault() { + assertEquals(DefaultDiscriminator.DEFAULT, discriminator.getDiscriminatingValue("an event")); + assertEquals(DefaultDiscriminator.DEFAULT, discriminator.getDiscriminatingValue(42)); + assertEquals(DefaultDiscriminator.DEFAULT, discriminator.getDiscriminatingValue(null)); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/sift/SiftingAppenderBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/sift/SiftingAppenderBaseTest.java index 314dbec8b..40195bd3c 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/sift/SiftingAppenderBaseTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/sift/SiftingAppenderBaseTest.java @@ -17,12 +17,25 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import org.junit.Test; +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.Context; import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.read.ListAppender; import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusChecker; import ch.qos.logback.core.status.StatusUtil; +import ch.qos.logback.core.util.Duration; public class SiftingAppenderBaseTest { @@ -49,4 +62,159 @@ public void startWithoutDiscriminatorReportsAnErrorInsteadOfThrowing() { assertFalse(appender.isStarted()); assertEquals(Status.ERROR, new StatusUtil(context).getHighestLevel(0)); } + + @Test + public void timeoutAndMaxAppenderCountHaveTheTrackerDefaults() { + assertEquals(AppenderTracker.DEFAULT_TIMEOUT, appender.getTimeout().getMilliseconds()); + assertEquals(AppenderTracker.DEFAULT_MAX_COMPONENTS, appender.getMaxAppenderCount()); + } + + @Test + public void timeoutAndMaxAppenderCountAreHandedToTheTrackerOnStart() { + StringSiftingAppender sa = newStringSiftingAppender(); + Duration timeout = Duration.buildBySeconds(5); + sa.setTimeout(timeout); + sa.setMaxAppenderCount(3); + + sa.start(); + + assertTrue(sa.isStarted()); + assertSame(timeout, sa.getTimeout()); + assertEquals(3, sa.getMaxAppenderCount()); + assertEquals(5000, sa.getAppenderTracker().getTimeout()); + assertEquals(3, sa.getAppenderTracker().getMaxComponents()); + } + + @Test + public void discriminatorAndItsKeyAreExposed() { + StringSiftingAppender sa = newStringSiftingAppender(); + assertSame(sa.discriminator, sa.getDiscriminator()); + assertEquals(PrefixDiscriminator.KEY, sa.getDiscriminatorKey()); + } + + @Test + public void discriminatorKeyIsNullWithoutDiscriminator() { + assertNull(appender.getDiscriminator()); + assertNull(appender.getDiscriminatorKey()); + } + + @Test + public void eventsAreDispatchedToTheAppenderOfTheirDiscriminatingValue() { + StringSiftingAppender sa = newStringSiftingAppender(); + sa.start(); + + sa.doAppend("a:1"); + sa.doAppend("b:1"); + sa.doAppend("a:2"); + + assertEquals(Arrays.asList("a:1", "a:2"), nestedEvents(sa, "a")); + assertEquals(Arrays.asList("b:1"), nestedEvents(sa, "b")); + } + + @Test + public void stopStopsLiveAndLingeringNestedAppenders() { + StringSiftingAppender sa = newStringSiftingAppender(); + sa.start(); + sa.doAppend("live:1"); + sa.doAppend("lingering:end"); + Appender live = sa.getAppenderTracker().find("live"); + Appender lingering = sa.getAppenderTracker().find("lingering"); + assertTrue(live.isStarted()); + assertTrue(lingering.isStarted()); + + sa.stop(); + + assertFalse(live.isStarted()); + assertFalse(lingering.isStarted()); + } + + @Test + public void stopWithoutNestedAppendersIsHarmless() { + StringSiftingAppender sa = newStringSiftingAppender(); + sa.start(); + + sa.stop(); + + assertEquals(0, sa.getAppenderTracker().getComponentCount()); + new StatusChecker(context).assertIsErrorFree(); + } + + @Test + public void appendIsIgnoredWhenNotStarted() { + StringSiftingAppender sa = newStringSiftingAppender(); + sa.discriminator.stop(); + sa.start(); + assertFalse(sa.isStarted()); + // start() still built the tracker, only the discriminator was faulty + assertNotNull(sa.getAppenderTracker()); + + // doAppend() would already refuse the event, so call append() directly + sa.append("a:1"); + + assertEquals(0, sa.getAppenderTracker().getComponentCount()); + assertTrue(sa.factory.builtAppenders.isEmpty()); + } + + private StringSiftingAppender newStringSiftingAppender() { + StringSiftingAppender sa = new StringSiftingAppender(); + sa.setContext(context); + return sa; + } + + private static List nestedEvents(StringSiftingAppender sa, String discriminatingValue) { + return ((ListAppender) sa.getAppenderTracker().find(discriminatingValue)).list; + } + + /** + * Sifts string events of the form "prefix:suffix" by prefix. An event whose + * suffix is "end" marks the end of life of its nested appender. + */ + static class StringSiftingAppender extends SiftingAppenderBase { + final RecordingListAppenderFactory factory = new RecordingListAppenderFactory(); + + StringSiftingAppender() { + PrefixDiscriminator prefixDiscriminator = new PrefixDiscriminator(); + prefixDiscriminator.start(); + setDiscriminator(prefixDiscriminator); + setAppenderFactory(factory); + } + + @Override + protected long getTimestamp(String event) { + return 0; + } + + @Override + protected boolean eventMarksEndOfLife(String event) { + return event.endsWith(":end"); + } + } + + static class PrefixDiscriminator extends AbstractDiscriminator { + static final String KEY = "prefix"; + + @Override + public String getDiscriminatingValue(String event) { + return event.substring(0, event.indexOf(':')); + } + + @Override + public String getKey() { + return KEY; + } + } + + static class RecordingListAppenderFactory implements AppenderFactory { + final List> builtAppenders = new ArrayList>(); + + @Override + public Appender buildAppender(Context context, String discriminatingValue) { + ListAppender la = new ListAppender(); + la.setContext(context); + la.setName(discriminatingValue); + la.start(); + builtAppenders.add(la); + return la; + } + } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/sift/SiftingJoranConfiguratorBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/sift/SiftingJoranConfiguratorBaseTest.java new file mode 100644 index 000000000..8c9733da0 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/sift/SiftingJoranConfiguratorBaseTest.java @@ -0,0 +1,119 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.sift; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.status.Status; + +public class SiftingJoranConfiguratorBaseTest { + + static final String NO_APPENDER_MSG = + "No nested appenders found within the element in SiftingAppender."; + static final String SEVERAL_APPENDERS_MSG = + "Only and only one appender can be nested the element in SiftingAppender. See also " + + CoreConstants.CODES_URL + "#1andOnly1"; + + ContextBase context = new ContextBase(); + TestSiftingJoranConfigurator configurator = new TestSiftingJoranConfigurator(); + + @Before + public void setUp() { + configurator.setContext(context); + } + + @Test + public void exactlyOneNestedAppenderIsAccepted() { + configurator.oneAndOnlyOneCheck(Collections.singletonMap("A", "appender")); + + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } + + @Test + public void missingNestedAppenderIsReported() { + configurator.oneAndOnlyOneCheck(Collections.emptyMap()); + + assertErrorMessages(NO_APPENDER_MSG); + } + + @Test + public void severalNestedAppendersAreReported() { + Map appenderMap = new HashMap(); + appenderMap.put("A", "appender A"); + appenderMap.put("B", "appender B"); + + configurator.oneAndOnlyOneCheck(appenderMap); + + assertErrorMessages(SEVERAL_APPENDERS_MSG); + } + + @Test + public void reportsStopOnceTheErrorCountReachesMaxErrorCount() { + Map appenderMap = new HashMap(); + appenderMap.put("A", "appender A"); + appenderMap.put("B", "appender B"); + + configurator.oneAndOnlyOneCheck(Collections.emptyMap()); + configurator.oneAndOnlyOneCheck(appenderMap); + // a valid map is not an error and does not count + configurator.oneAndOnlyOneCheck(Collections.singletonMap("A", "appender")); + configurator.oneAndOnlyOneCheck(Collections.emptyMap()); + // from here on errorEmmissionCount >= MAX_ERROR_COUNT (4) + configurator.oneAndOnlyOneCheck(Collections.emptyMap()); + configurator.oneAndOnlyOneCheck(appenderMap); + + assertEquals(4, CoreConstants.MAX_ERROR_COUNT); + assertErrorMessages(NO_APPENDER_MSG, SEVERAL_APPENDERS_MSG, NO_APPENDER_MSG); + } + + @Test + public void toStringShowsClassKeyAndValue() { + assertEquals(TestSiftingJoranConfigurator.class.getName() + "{userid=alice}", + configurator.toString()); + } + + private void assertErrorMessages(String... expectedMessages) { + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(statuses.toString(), expectedMessages.length, statuses.size()); + for (int i = 0; i < expectedMessages.length; i++) { + assertEquals(Status.ERROR, statuses.get(i).getLevel()); + assertEquals(expectedMessages[i], statuses.get(i).getMessage()); + } + } + + static class TestSiftingJoranConfigurator extends SiftingJoranConfiguratorBase { + TestSiftingJoranConfigurator() { + super("userid", "alice", Collections.emptyMap()); + } + + @Override + public Appender getAppender() { + return null; + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/spi/AbstractComponentTrackerTest.java b/logback-android/src/test/java/ch/qos/logback/core/spi/AbstractComponentTrackerTest.java new file mode 100644 index 000000000..1d11df2fa --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/spi/AbstractComponentTrackerTest.java @@ -0,0 +1,232 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.spi; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.Test; + +/** + * Tests the parts of {@link AbstractComponentTracker} that are not exercised + * through its concrete subclasses: stale components, {@code allComponents()}, + * the timeout/max-components getters and the internal entry's + * {@code equals}/{@code hashCode}/{@code toString}. + */ +public class AbstractComponentTrackerTest { + + long now = 3000; + + @Test + public void timeoutAndMaxComponentsHaveDefaultsAndCanBeChanged() { + StringComponentTracker tracker = new StringComponentTracker(); + assertEquals(ComponentTracker.DEFAULT_TIMEOUT, tracker.getTimeout()); + assertEquals(ComponentTracker.DEFAULT_MAX_COMPONENTS, tracker.getMaxComponents()); + + tracker.setTimeout(1234); + tracker.setMaxComponents(7); + + assertEquals(1234, tracker.getTimeout()); + assertEquals(7, tracker.getMaxComponents()); + } + + @Test + public void staleComponentIsRemovedBeforeItsTimeoutExpires() { + StringComponentTracker tracker = new StringComponentTracker(); + String stale = tracker.getOrCreate("stale", now); + String fresh = tracker.getOrCreate("fresh", now); + tracker.staleComponents.add(stale); + + // well within the (default, 30 minutes) timeout + tracker.removeStaleComponents(now + 1); + + assertNull(tracker.find("stale")); + assertSame(fresh, tracker.find("fresh")); + assertEquals(Collections.singletonList(stale), tracker.removedComponents); + assertEquals(1, tracker.getComponentCount()); + } + + @Test + public void nonStaleComponentIsKeptUntilItsTimeoutExpires() { + StringComponentTracker tracker = new StringComponentTracker(); + String component = tracker.getOrCreate("k", now); + + tracker.removeStaleComponents(now + ComponentTracker.DEFAULT_TIMEOUT); + assertSame(component, tracker.find("k")); + assertTrue(tracker.removedComponents.isEmpty()); + + // the next removal iteration may only run WAIT_BETWEEN_SUCCESSIVE_REMOVAL_ITERATIONS later + tracker.removeStaleComponents(now + ComponentTracker.DEFAULT_TIMEOUT + + AbstractComponentTracker.WAIT_BETWEEN_SUCCESSIVE_REMOVAL_ITERATIONS); + assertNull(tracker.find("k")); + assertEquals(Collections.singletonList(component), tracker.removedComponents); + } + + @Test + public void allComponentsReturnsLiveAndLingeringComponents() { + StringComponentTracker tracker = new StringComponentTracker(); + String live1 = tracker.getOrCreate("live1", now); + String live2 = tracker.getOrCreate("live2", now); + String lingering = tracker.getOrCreate("lingering", now); + tracker.endOfLife("lingering"); + + assertThat(tracker.allComponents(), containsInAnyOrder(live1, live2, lingering)); + assertEquals(new HashSet(Arrays.asList("live1", "live2", "lingering")), + tracker.allKeys()); + } + + @Test + public void allComponentsOfAnEmptyTrackerIsEmpty() { + assertTrue(new StringComponentTracker().allComponents().isEmpty()); + } + + @Test + public void allComponentsReturnsASnapshot() { + StringComponentTracker tracker = new StringComponentTracker(); + String component = tracker.getOrCreate("k", now); + List snapshot = new ArrayList(tracker.allComponents()); + + tracker.allComponents().clear(); + + assertEquals(Collections.singletonList(component), snapshot); + assertSame(component, tracker.find("k")); + } + + @Test + public void entryToStringShowsKeyAndComponent() { + StringComponentTracker tracker = new StringComponentTracker(); + tracker.getOrCreate("k", now); + + assertEquals("(k, component-k)", entryOf(tracker, "k").toString()); + } + + @Test + public void entryHashCodeIsTheHashCodeOfItsKey() { + StringComponentTracker tracker = new StringComponentTracker(); + tracker.getOrCreate("some key", now); + + assertEquals("some key".hashCode(), entryOf(tracker, "some key").hashCode()); + } + + @Test + public void entryEqualsItselfButNotNullNorOtherTypes() { + StringComponentTracker tracker = new StringComponentTracker(); + tracker.getOrCreate("k", now); + Object entry = entryOf(tracker, "k"); + + assertTrue(entry.equals(entry)); + assertFalse(entry.equals(null)); + assertFalse(entry.equals("(k, component-k)")); + } + + @Test + public void entriesWithSameKeyAndComponentAreEqualWhateverTheirTimestamp() { + Object entry = entry("k", "c", now); + Object other = entry("k", "c", now + 10); + + assertEquals(entry, other); + assertEquals(entry.hashCode(), other.hashCode()); + } + + @Test + public void entriesWithDifferentKeysAreNotEqual() { + assertNotEquals(entry("k1", "c", now), entry("k2", "c", now)); + } + + @Test + public void entriesWithDifferentComponentsAreNotEqual() { + assertNotEquals(entry("k", "c1", now), entry("k", "c2", now)); + } + + @Test + public void entryWithNullKeyEqualsOnlyEntriesWithNullKey() { + Object nullKeyEntry = entry(null, "c", now); + + assertNotEquals(nullKeyEntry, entry("k", "c", now)); + assertEquals(nullKeyEntry, entry(null, "c", now)); + // and the other way around + assertNotEquals(entry("k", "c", now), nullKeyEntry); + } + + @Test + public void entryWithNullComponentEqualsOnlyEntriesWithNullComponent() { + Object nullComponentEntry = entry("k", null, now); + + assertNotEquals(nullComponentEntry, entry("k", "c", now)); + assertEquals(nullComponentEntry, entry("k", null, now)); + // and the other way around + assertNotEquals(entry("k", "c", now), nullComponentEntry); + } + + /** + * Builds, through the public API of a fresh tracker, the internal entry + * mapping {@code key} to {@code component}. + */ + private static Object entry(String key, String component, long timestamp) { + StringComponentTracker tracker = new StringComponentTracker(); + tracker.componentsToBuild.put(key, component); + assertEquals(component, tracker.getOrCreate(key, timestamp)); + return entryOf(tracker, key); + } + + private static Object entryOf(AbstractComponentTracker tracker, String key) { + assertTrue(tracker.liveMap.containsKey(key)); + return tracker.liveMap.get(key); + } + + /** + * A tracker of strings: the component built for a key is looked up in + * {@link #componentsToBuild}, and defaults to "component-" + key. + */ + static class StringComponentTracker extends AbstractComponentTracker { + final Map componentsToBuild = new HashMap(); + final Set staleComponents = new HashSet(); + final List removedComponents = new ArrayList(); + + @Override + protected void processPriorToRemoval(String component) { + removedComponents.add(component); + } + + @Override + protected String buildComponent(String key) { + if (componentsToBuild.containsKey(key)) { + return componentsToBuild.get(key); + } + return "component-" + key; + } + + @Override + protected boolean isComponentStale(String component) { + return staleComponents.contains(component); + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/spi/AppenderAttachableImplTest.java b/logback-android/src/test/java/ch/qos/logback/core/spi/AppenderAttachableImplTest.java index 3ffbe1e16..ce12aef81 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/spi/AppenderAttachableImplTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/spi/AppenderAttachableImplTest.java @@ -20,6 +20,8 @@ import static junit.framework.Assert.assertNotNull; import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; import java.util.Iterator; @@ -164,6 +166,71 @@ public void testDetachAppenderByName() throws Exception { assertFalse( aai.detachAppender("test1")); } + @Test + public void addingANullAppenderIsRejected() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> aai.addAppender(null)); + assertEquals("Null argument disallowed", e.getMessage()); + assertFalse(aai.iteratorForAppenders().hasNext()); + } + + @Test + public void addingTheSameAppenderTwiceAttachesItOnce() { + NOPAppender ta = new NOPAppender(); + aai.addAppender(ta); + aai.addAppender(ta); + assertEquals(1, aai.appendLoopOnAppenders(new TestEvent())); + } + + @Test + public void getAppenderWithNullNameReturnsNull() { + NOPAppender unnamed = new NOPAppender(); + aai.addAppender(unnamed); + assertNull(aai.getAppender(null)); + } + + @Test + public void nullOrUnattachedAppenderIsNotAttached() { + NOPAppender attached = new NOPAppender(); + attached.setName("same"); + aai.addAppender(attached); + NOPAppender other = new NOPAppender(); + other.setName("same"); + + assertFalse(aai.isAttached(null)); + // attachment is by identity, not by name + assertFalse(aai.isAttached(other)); + assertTrue(aai.isAttached(attached)); + } + + @Test + public void detachingANullAppenderReturnsFalseAndKeepsTheOthers() { + NOPAppender ta = new NOPAppender(); + aai.addAppender(ta); + + assertFalse(aai.detachAppender((Appender) null)); + assertTrue(aai.isAttached(ta)); + } + + @Test + public void detachingByNullNameReturnsFalseAndKeepsUnnamedAppenders() { + NOPAppender unnamed = new NOPAppender(); + aai.addAppender(unnamed); + + assertFalse(aai.detachAppender((String) null)); + assertTrue(aai.isAttached(unnamed)); + } + + @Test + public void detachingByUnknownNameReturnsFalseAndKeepsTheOthers() { + NOPAppender ta = new NOPAppender(); + ta.setName("known"); + aai.addAppender(ta); + + assertFalse(aai.detachAppender("unknown")); + assertTrue(aai.isAttached(ta)); + } + private static class TestEvent { } diff --git a/logback-android/src/test/java/ch/qos/logback/core/spi/ContextAwareBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/spi/ContextAwareBaseTest.java new file mode 100644 index 000000000..b09837479 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/spi/ContextAwareBaseTest.java @@ -0,0 +1,156 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.spi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.io.UnsupportedEncodingException; +import java.util.List; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.status.Status; + +public class ContextAwareBaseTest { + + static final String NO_CONTEXT_WARNING = "LOGBACK: No context given for "; + + Context context = new ContextBase(); + ContextAwareBase cab = new ContextAwareBase(); + + PrintStream originalOut; + ByteArrayOutputStream capturedOut = new ByteArrayOutputStream(); + + @Before + public void setUp() throws UnsupportedEncodingException { + originalOut = System.out; + System.setOut(new PrintStream(capturedOut, true, "UTF-8")); + } + + @After + public void tearDown() { + System.setOut(originalOut); + } + + @Test + public void settingTheSameContextAgainIsAllowed() { + cab.setContext(context); + cab.setContext(context); + assertSame(context, cab.getContext()); + } + + @Test + public void settingAnotherContextIsRejected() { + cab.setContext(context); + ContextBase other = new ContextBase(); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> cab.setContext(other)); + + assertEquals("Context has been already set", e.getMessage()); + assertSame(context, cab.getContext()); + } + + @Test + public void statusManagerIsNullWithoutContext() { + assertNull(cab.getStatusManager()); + } + + @Test + public void statusManagerIsTheContextStatusManager() { + cab.setContext(context); + assertSame(context.getStatusManager(), cab.getStatusManager()); + } + + @Test + public void everyStatusFlavorReachesTheStatusManagerWithThisAsOrigin() { + cab.setContext(context); + Exception infoEx = new Exception("i"); + Exception warnEx = new Exception("w"); + Exception errorEx = new Exception("e"); + + cab.addInfo("info"); + cab.addInfo("info with ex", infoEx); + cab.addWarn("warn"); + cab.addWarn("warn with ex", warnEx); + cab.addError("error"); + cab.addError("error with ex", errorEx); + + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(6, statuses.size()); + assertStatus(statuses.get(0), Status.INFO, "info", null, cab); + assertStatus(statuses.get(1), Status.INFO, "info with ex", infoEx, cab); + assertStatus(statuses.get(2), Status.WARN, "warn", null, cab); + assertStatus(statuses.get(3), Status.WARN, "warn with ex", warnEx, cab); + assertStatus(statuses.get(4), Status.ERROR, "error", null, cab); + assertStatus(statuses.get(5), Status.ERROR, "error with ex", errorEx, cab); + } + + @Test + public void declaredOriginIsTheOriginOfStatuses() { + ContextAwareBase declaredOrigin = new ContextAwareBase(); + ContextAwareBase delegate = new ContextAwareBase(declaredOrigin); + delegate.setContext(context); + + delegate.addInfo("hello"); + + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(1, statuses.size()); + assertStatus(statuses.get(0), Status.INFO, "hello", null, declaredOrigin); + } + + @Test + public void withoutContextStatusesAreDroppedAndReportedOnlyOnce() throws UnsupportedEncodingException { + cab.addInfo("first"); + cab.addError("second"); + cab.addWarn("third"); + + String out = capturedOut.toString("UTF-8"); + assertTrue(out, out.startsWith(NO_CONTEXT_WARNING + cab)); + assertEquals(out, 1, out.split(NO_CONTEXT_WARNING, -1).length - 1); + assertNull(cab.getContext()); + } + + @Test + public void contextWithoutStatusManagerSilentlyDropsStatuses() throws UnsupportedEncodingException { + Context contextWithoutStatusManager = mock(Context.class); + cab.setContext(contextWithoutStatusManager); + + cab.addError("dropped"); + + verify(contextWithoutStatusManager).getStatusManager(); + assertEquals("", capturedOut.toString("UTF-8")); + } + + private static void assertStatus(Status status, int level, String message, Throwable throwable, + Object origin) { + assertEquals(level, status.getLevel()); + assertEquals(message, status.getMessage()); + assertSame(throwable, status.getThrowable()); + assertSame(origin, status.getOrigin()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/spi/ContextAwareImplTest.java b/logback-android/src/test/java/ch/qos/logback/core/spi/ContextAwareImplTest.java new file mode 100644 index 000000000..dd07b64e6 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/spi/ContextAwareImplTest.java @@ -0,0 +1,152 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.spi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.io.UnsupportedEncodingException; +import java.util.List; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.status.Status; + +public class ContextAwareImplTest { + + static final String NO_CONTEXT_WARNING = "LOGBACK: No context given for "; + + Context context = new ContextBase(); + Object origin = "the origin"; + + PrintStream originalOut; + ByteArrayOutputStream capturedOut = new ByteArrayOutputStream(); + + @Before + public void setUp() throws UnsupportedEncodingException { + originalOut = System.out; + System.setOut(new PrintStream(capturedOut, true, "UTF-8")); + } + + @After + public void tearDown() { + System.setOut(originalOut); + } + + @Test + public void contextCanBeSetWhenNoneWasGiven() { + ContextAwareImpl cai = new ContextAwareImpl(null, origin); + assertNull(cai.getContext()); + + cai.setContext(context); + + assertSame(context, cai.getContext()); + } + + @Test + public void settingTheSameContextAgainIsAllowed() { + ContextAwareImpl cai = new ContextAwareImpl(context, origin); + cai.setContext(context); + assertSame(context, cai.getContext()); + } + + @Test + public void settingAnotherContextIsRejected() { + ContextAwareImpl cai = new ContextAwareImpl(context, origin); + ContextBase other = new ContextBase(); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> cai.setContext(other)); + + assertEquals("Context has been already set", e.getMessage()); + assertSame(context, cai.getContext()); + } + + @Test + public void statusManagerIsNullWithoutContext() { + assertNull(new ContextAwareImpl(null, origin).getStatusManager()); + } + + @Test + public void statusManagerIsTheContextStatusManager() { + assertSame(context.getStatusManager(), new ContextAwareImpl(context, origin).getStatusManager()); + } + + @Test + public void everyStatusFlavorReachesTheStatusManagerWithTheGivenOrigin() { + ContextAwareImpl cai = new ContextAwareImpl(context, origin); + Exception infoEx = new Exception("i"); + Exception warnEx = new Exception("w"); + Exception errorEx = new Exception("e"); + + cai.addInfo("info"); + cai.addInfo("info with ex", infoEx); + cai.addWarn("warn"); + cai.addWarn("warn with ex", warnEx); + cai.addError("error"); + cai.addError("error with ex", errorEx); + + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(6, statuses.size()); + assertStatus(statuses.get(0), Status.INFO, "info", null); + assertStatus(statuses.get(1), Status.INFO, "info with ex", infoEx); + assertStatus(statuses.get(2), Status.WARN, "warn", null); + assertStatus(statuses.get(3), Status.WARN, "warn with ex", warnEx); + assertStatus(statuses.get(4), Status.ERROR, "error", null); + assertStatus(statuses.get(5), Status.ERROR, "error with ex", errorEx); + } + + @Test + public void withoutContextStatusesAreDroppedAndReportedOnlyOnce() throws UnsupportedEncodingException { + ContextAwareImpl cai = new ContextAwareImpl(null, origin); + + cai.addInfo("first"); + cai.addError("second"); + cai.addWarn("third"); + + String out = capturedOut.toString("UTF-8"); + assertTrue(out, out.startsWith(NO_CONTEXT_WARNING + origin)); + assertEquals(out, 1, out.split(NO_CONTEXT_WARNING, -1).length - 1); + } + + @Test + public void contextWithoutStatusManagerSilentlyDropsStatuses() throws UnsupportedEncodingException { + Context contextWithoutStatusManager = mock(Context.class); + ContextAwareImpl cai = new ContextAwareImpl(contextWithoutStatusManager, origin); + + cai.addError("dropped"); + + verify(contextWithoutStatusManager).getStatusManager(); + assertEquals("", capturedOut.toString("UTF-8")); + } + + private void assertStatus(Status status, int level, String message, Throwable throwable) { + assertEquals(level, status.getLevel()); + assertEquals(message, status.getMessage()); + assertSame(throwable, status.getThrowable()); + assertSame(origin, status.getOrigin()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/spi/CyclicBufferTrackerTest.java b/logback-android/src/test/java/ch/qos/logback/core/spi/CyclicBufferTrackerTest.java index e47c0507e..99ecb11c7 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/spi/CyclicBufferTrackerTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/spi/CyclicBufferTrackerTest.java @@ -75,6 +75,25 @@ public void destroy() { assertEquals(0, cb.length()); } + @Test + public void buffersHaveTheDefaultSizeUnlessConfigured() { + assertEquals(CyclicBufferTracker.DEFAULT_BUFFER_SIZE, tracker.getBufferSize()); + assertEquals(CyclicBufferTracker.DEFAULT_BUFFER_SIZE, tracker.getOrCreate(key, 3000).getMaxSize()); + } + + @Test + public void configuredBufferSizeAppliesToBuffersCreatedAfterwards() { + tracker.setBufferSize(3); + + assertEquals(3, tracker.getBufferSize()); + CyclicBuffer cb = tracker.getOrCreate(key, 3000); + assertEquals(3, cb.getMaxSize()); + for (int i = 0; i < 5; i++) { + cb.add(i); + } + assertEquals(3, cb.length()); + } + diff --git a/logback-android/src/test/java/ch/qos/logback/core/spi/FilterAttachableImplTest.java b/logback-android/src/test/java/ch/qos/logback/core/spi/FilterAttachableImplTest.java new file mode 100644 index 000000000..065b9e890 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/spi/FilterAttachableImplTest.java @@ -0,0 +1,125 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.spi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.Test; + +import ch.qos.logback.core.filter.Filter; + +public class FilterAttachableImplTest { + + FilterAttachableImpl fai = new FilterAttachableImpl(); + Object event = new Object(); + + @Test + public void noFiltersMeansNeutral() { + assertEquals(FilterReply.NEUTRAL, fai.getFilterChainDecision(event)); + } + + @Test + public void neutralFiltersAreAllConsultedAndYieldNeutral() { + RecordingFilter first = new RecordingFilter(FilterReply.NEUTRAL); + RecordingFilter second = new RecordingFilter(FilterReply.NEUTRAL); + fai.addFilter(first); + fai.addFilter(second); + + assertEquals(FilterReply.NEUTRAL, fai.getFilterChainDecision(event)); + assertEquals(Arrays.asList(event), first.seen); + assertEquals(Arrays.asList(event), second.seen); + } + + @Test + public void firstAcceptWinsAndStopsTheChain() { + RecordingFilter neutral = new RecordingFilter(FilterReply.NEUTRAL); + RecordingFilter accept = new RecordingFilter(FilterReply.ACCEPT); + RecordingFilter deny = new RecordingFilter(FilterReply.DENY); + fai.addFilter(neutral); + fai.addFilter(accept); + fai.addFilter(deny); + + assertEquals(FilterReply.ACCEPT, fai.getFilterChainDecision(event)); + assertEquals(1, neutral.seen.size()); + assertEquals(1, accept.seen.size()); + assertTrue(deny.seen.isEmpty()); + } + + @Test + public void firstDenyWinsAndStopsTheChain() { + RecordingFilter neutral = new RecordingFilter(FilterReply.NEUTRAL); + RecordingFilter deny = new RecordingFilter(FilterReply.DENY); + RecordingFilter accept = new RecordingFilter(FilterReply.ACCEPT); + fai.addFilter(neutral); + fai.addFilter(deny); + fai.addFilter(accept); + + assertEquals(FilterReply.DENY, fai.getFilterChainDecision(event)); + assertEquals(1, neutral.seen.size()); + assertEquals(1, deny.seen.size()); + assertTrue(accept.seen.isEmpty()); + } + + @Test + public void copyOfAttachedFiltersListHasTheFiltersInOrderAndIsDetached() { + RecordingFilter first = new RecordingFilter(FilterReply.NEUTRAL); + RecordingFilter second = new RecordingFilter(FilterReply.DENY); + fai.addFilter(first); + fai.addFilter(second); + + List> copy = fai.getCopyOfAttachedFiltersList(); + assertEquals(2, copy.size()); + assertSame(first, copy.get(0)); + assertSame(second, copy.get(1)); + + copy.clear(); + assertEquals(2, fai.getCopyOfAttachedFiltersList().size()); + } + + @Test + public void clearAllFiltersRemovesEveryFilter() { + RecordingFilter deny = new RecordingFilter(FilterReply.DENY); + fai.addFilter(deny); + fai.addFilter(new RecordingFilter(FilterReply.ACCEPT)); + + fai.clearAllFilters(); + + assertTrue(fai.getCopyOfAttachedFiltersList().isEmpty()); + assertEquals(FilterReply.NEUTRAL, fai.getFilterChainDecision(event)); + assertTrue(deny.seen.isEmpty()); + } + + static class RecordingFilter extends Filter { + final FilterReply reply; + final List seen = new ArrayList(); + + RecordingFilter(FilterReply reply) { + this.reply = reply; + } + + @Override + public FilterReply decide(Object event) { + seen.add(event); + return reply; + } + } +} From 2e8795d5652d208979267dd02adb69dc8b3f9514 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:34:18 +0000 Subject: [PATCH 72/79] test: pin status origins and event timestamps in spi/sift tests Mutation testing showed three gaps in the new tests: - ContextAwareBase.addWarn/addError/addInfo(msg, ex) could report `this` instead of the declared origin, since only addInfo(msg) was checked with a distinct declared origin. - An overridden ContextAwareBase.getDeclaredOrigin() or ContextAwareImpl.getOrigin() was never exercised. - SiftingAppenderBase.append() could ignore the event timestamp when refreshing a nested appender's access time. Check all six status flavors against the declared and the overridden origin, and add a test in which a busy nested appender outlives an idle one that times out. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../core/sift/SiftingAppenderBaseTest.java | 29 ++++++++- .../core/spi/ContextAwareBaseTest.java | 62 ++++++++++++------- .../core/spi/ContextAwareImplTest.java | 57 +++++++++++------ 3 files changed, 105 insertions(+), 43 deletions(-) diff --git a/logback-android/src/test/java/ch/qos/logback/core/sift/SiftingAppenderBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/sift/SiftingAppenderBaseTest.java index 40195bd3c..7690f9b85 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/sift/SiftingAppenderBaseTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/sift/SiftingAppenderBaseTest.java @@ -111,6 +111,31 @@ public void eventsAreDispatchedToTheAppenderOfTheirDiscriminatingValue() { assertEquals(Arrays.asList("b:1"), nestedEvents(sa, "b")); } + @Test + public void eventTimestampsKeepTheirNestedAppenderAliveWhileIdleOnesTimeOut() { + StringSiftingAppender sa = newStringSiftingAppender(); + sa.setTimeout(Duration.buildByMilliseconds(10000)); + sa.start(); + sa.now = 3000; + sa.doAppend("idle:1"); + sa.doAppend("busy:1"); + Appender idle = sa.getAppenderTracker().find("idle"); + Appender busy = sa.getAppenderTracker().find("busy"); + + // "busy" is used again within the timeout, "idle" is not + sa.now = 3000 + 9000; + sa.doAppend("busy:2"); + // past the timeout of "idle" (3000 + 10000) but not of "busy" (12000 + 10000) + sa.now = 3000 + 10001; + sa.doAppend("busy:3"); + + assertNull(sa.getAppenderTracker().find("idle")); + assertFalse(idle.isStarted()); + assertSame(busy, sa.getAppenderTracker().find("busy")); + assertTrue(busy.isStarted()); + assertEquals(Arrays.asList("busy:1", "busy:2", "busy:3"), nestedEvents(sa, "busy")); + } + @Test public void stopStopsLiveAndLingeringNestedAppenders() { StringSiftingAppender sa = newStringSiftingAppender(); @@ -171,6 +196,8 @@ private static List nestedEvents(StringSiftingAppender sa, String discri */ static class StringSiftingAppender extends SiftingAppenderBase { final RecordingListAppenderFactory factory = new RecordingListAppenderFactory(); + /** the timestamp of the events appended next */ + long now = 0; StringSiftingAppender() { PrefixDiscriminator prefixDiscriminator = new PrefixDiscriminator(); @@ -181,7 +208,7 @@ static class StringSiftingAppender extends SiftingAppenderBase { @Override protected long getTimestamp(String event) { - return 0; + return now; } @Override diff --git a/logback-android/src/test/java/ch/qos/logback/core/spi/ContextAwareBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/spi/ContextAwareBaseTest.java index b09837479..f2d646f09 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/spi/ContextAwareBaseTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/spi/ContextAwareBaseTest.java @@ -89,38 +89,31 @@ public void statusManagerIsTheContextStatusManager() { @Test public void everyStatusFlavorReachesTheStatusManagerWithThisAsOrigin() { cab.setContext(context); - Exception infoEx = new Exception("i"); - Exception warnEx = new Exception("w"); - Exception errorEx = new Exception("e"); - cab.addInfo("info"); - cab.addInfo("info with ex", infoEx); - cab.addWarn("warn"); - cab.addWarn("warn with ex", warnEx); - cab.addError("error"); - cab.addError("error with ex", errorEx); - - List statuses = context.getStatusManager().getCopyOfStatusList(); - assertEquals(6, statuses.size()); - assertStatus(statuses.get(0), Status.INFO, "info", null, cab); - assertStatus(statuses.get(1), Status.INFO, "info with ex", infoEx, cab); - assertStatus(statuses.get(2), Status.WARN, "warn", null, cab); - assertStatus(statuses.get(3), Status.WARN, "warn with ex", warnEx, cab); - assertStatus(statuses.get(4), Status.ERROR, "error", null, cab); - assertStatus(statuses.get(5), Status.ERROR, "error with ex", errorEx, cab); + assertEveryStatusFlavorIsAddedWithOrigin(cab, cab); } @Test - public void declaredOriginIsTheOriginOfStatuses() { + public void declaredOriginIsTheOriginOfEveryStatusFlavor() { ContextAwareBase declaredOrigin = new ContextAwareBase(); ContextAwareBase delegate = new ContextAwareBase(declaredOrigin); delegate.setContext(context); - delegate.addInfo("hello"); + assertEveryStatusFlavorIsAddedWithOrigin(delegate, declaredOrigin); + } - List statuses = context.getStatusManager().getCopyOfStatusList(); - assertEquals(1, statuses.size()); - assertStatus(statuses.get(0), Status.INFO, "hello", null, declaredOrigin); + @Test + public void overriddenDeclaredOriginIsTheOriginOfEveryStatusFlavor() { + final Object overriddenOrigin = new Object(); + ContextAwareBase subclass = new ContextAwareBase() { + @Override + protected Object getDeclaredOrigin() { + return overriddenOrigin; + } + }; + subclass.setContext(context); + + assertEveryStatusFlavorIsAddedWithOrigin(subclass, overriddenOrigin); } @Test @@ -146,6 +139,29 @@ public void contextWithoutStatusManagerSilentlyDropsStatuses() throws Unsupporte assertEquals("", capturedOut.toString("UTF-8")); } + private void assertEveryStatusFlavorIsAddedWithOrigin(ContextAwareBase contextAware, + Object expectedOrigin) { + Exception infoEx = new Exception("i"); + Exception warnEx = new Exception("w"); + Exception errorEx = new Exception("e"); + + contextAware.addInfo("info"); + contextAware.addInfo("info with ex", infoEx); + contextAware.addWarn("warn"); + contextAware.addWarn("warn with ex", warnEx); + contextAware.addError("error"); + contextAware.addError("error with ex", errorEx); + + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(6, statuses.size()); + assertStatus(statuses.get(0), Status.INFO, "info", null, expectedOrigin); + assertStatus(statuses.get(1), Status.INFO, "info with ex", infoEx, expectedOrigin); + assertStatus(statuses.get(2), Status.WARN, "warn", null, expectedOrigin); + assertStatus(statuses.get(3), Status.WARN, "warn with ex", warnEx, expectedOrigin); + assertStatus(statuses.get(4), Status.ERROR, "error", null, expectedOrigin); + assertStatus(statuses.get(5), Status.ERROR, "error with ex", errorEx, expectedOrigin); + } + private static void assertStatus(Status status, int level, String message, Throwable throwable, Object origin) { assertEquals(level, status.getLevel()); diff --git a/logback-android/src/test/java/ch/qos/logback/core/spi/ContextAwareImplTest.java b/logback-android/src/test/java/ch/qos/logback/core/spi/ContextAwareImplTest.java index dd07b64e6..a5192728c 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/spi/ContextAwareImplTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/spi/ContextAwareImplTest.java @@ -98,25 +98,21 @@ public void statusManagerIsTheContextStatusManager() { @Test public void everyStatusFlavorReachesTheStatusManagerWithTheGivenOrigin() { ContextAwareImpl cai = new ContextAwareImpl(context, origin); - Exception infoEx = new Exception("i"); - Exception warnEx = new Exception("w"); - Exception errorEx = new Exception("e"); - cai.addInfo("info"); - cai.addInfo("info with ex", infoEx); - cai.addWarn("warn"); - cai.addWarn("warn with ex", warnEx); - cai.addError("error"); - cai.addError("error with ex", errorEx); + assertEveryStatusFlavorIsAddedWithOrigin(cai, origin); + } - List statuses = context.getStatusManager().getCopyOfStatusList(); - assertEquals(6, statuses.size()); - assertStatus(statuses.get(0), Status.INFO, "info", null); - assertStatus(statuses.get(1), Status.INFO, "info with ex", infoEx); - assertStatus(statuses.get(2), Status.WARN, "warn", null); - assertStatus(statuses.get(3), Status.WARN, "warn with ex", warnEx); - assertStatus(statuses.get(4), Status.ERROR, "error", null); - assertStatus(statuses.get(5), Status.ERROR, "error with ex", errorEx); + @Test + public void overriddenOriginIsTheOriginOfEveryStatusFlavor() { + final Object overriddenOrigin = new Object(); + ContextAwareImpl cai = new ContextAwareImpl(context, origin) { + @Override + protected Object getOrigin() { + return overriddenOrigin; + } + }; + + assertEveryStatusFlavorIsAddedWithOrigin(cai, overriddenOrigin); } @Test @@ -143,10 +139,33 @@ public void contextWithoutStatusManagerSilentlyDropsStatuses() throws Unsupporte assertEquals("", capturedOut.toString("UTF-8")); } - private void assertStatus(Status status, int level, String message, Throwable throwable) { + private void assertEveryStatusFlavorIsAddedWithOrigin(ContextAwareImpl cai, Object expectedOrigin) { + Exception infoEx = new Exception("i"); + Exception warnEx = new Exception("w"); + Exception errorEx = new Exception("e"); + + cai.addInfo("info"); + cai.addInfo("info with ex", infoEx); + cai.addWarn("warn"); + cai.addWarn("warn with ex", warnEx); + cai.addError("error"); + cai.addError("error with ex", errorEx); + + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(6, statuses.size()); + assertStatus(statuses.get(0), Status.INFO, "info", null, expectedOrigin); + assertStatus(statuses.get(1), Status.INFO, "info with ex", infoEx, expectedOrigin); + assertStatus(statuses.get(2), Status.WARN, "warn", null, expectedOrigin); + assertStatus(statuses.get(3), Status.WARN, "warn with ex", warnEx, expectedOrigin); + assertStatus(statuses.get(4), Status.ERROR, "error", null, expectedOrigin); + assertStatus(statuses.get(5), Status.ERROR, "error with ex", errorEx, expectedOrigin); + } + + private static void assertStatus(Status status, int level, String message, Throwable throwable, + Object expectedOrigin) { assertEquals(level, status.getLevel()); assertEquals(message, status.getMessage()); assertSame(throwable, status.getThrowable()); - assertSame(origin, status.getOrigin()); + assertSame(expectedOrigin, status.getOrigin()); } } From 87129c1fd3cc9c9e397835d2487713480b1987af Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:29:41 +0000 Subject: [PATCH 73/79] refactor: fold the nested-property IAs' unreachable enum-switch defaults NestedBasicPropertyIA.isApplicable() and NestedComplexPropertyIA.isApplicable() switch on the AggregationType computed by PropertySetter and list all five constants, so javac's switch map sends every value to a case and the default branch (an "unexpected type" error followed by "not applicable") can never run. Reaching it would take rewriting javac's synthetic switch map through reflection, i.e. testing compiler internals, not behavior. Put the default label on the "not applicable" case, as done for TokenStream's switches. Every AggregationType keeps its behavior: the basic types (resp. complex types) push their action data and return true, the others return false without a status message. The added tests pin that for each type and for an empty object stack. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../joran/action/NestedBasicPropertyIA.java | 4 +- .../joran/action/NestedComplexPropertyIA.java | 5 +- .../action/NestedBasicPropertyIATest.java | 127 +++++++++++++++++ .../action/NestedComplexPropertyIATest.java | 131 ++++++++++++++++++ 4 files changed, 260 insertions(+), 7 deletions(-) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/action/NestedBasicPropertyIATest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/action/NestedComplexPropertyIATest.java diff --git a/logback-android/src/main/java/ch/qos/logback/core/joran/action/NestedBasicPropertyIA.java b/logback-android/src/main/java/ch/qos/logback/core/joran/action/NestedBasicPropertyIA.java index 04cb81f45..8a9047cb0 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/joran/action/NestedBasicPropertyIA.java +++ b/logback-android/src/main/java/ch/qos/logback/core/joran/action/NestedBasicPropertyIA.java @@ -65,6 +65,7 @@ public boolean isApplicable(ElementPath elementPath, Attributes attributes, case NOT_FOUND: case AS_COMPLEX_PROPERTY: case AS_COMPLEX_PROPERTY_COLLECTION: + default: // every AggregationType has a case; default shares this one's return false; case AS_BASIC_PROPERTY: @@ -74,9 +75,6 @@ public boolean isApplicable(ElementPath elementPath, Attributes attributes, actionDataStack.push(ad); // addInfo("NestedSimplePropertyIA deemed applicable [" + pattern + "]"); return true; - default: - addError("PropertySetter.canContainComponent returned " + aggregationType); - return false; } } diff --git a/logback-android/src/main/java/ch/qos/logback/core/joran/action/NestedComplexPropertyIA.java b/logback-android/src/main/java/ch/qos/logback/core/joran/action/NestedComplexPropertyIA.java index 7a566f664..9e031b07c 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/joran/action/NestedComplexPropertyIA.java +++ b/logback-android/src/main/java/ch/qos/logback/core/joran/action/NestedComplexPropertyIA.java @@ -68,6 +68,7 @@ public boolean isApplicable(ElementPath elementPath, Attributes attributes, case NOT_FOUND: case AS_BASIC_PROPERTY: case AS_BASIC_PROPERTY_COLLECTION: + default: // every AggregationType has a case; default shares this one's return false; // we only push action data if NestComponentIA is applicable @@ -78,10 +79,6 @@ public boolean isApplicable(ElementPath elementPath, Attributes attributes, actionDataStack.push(ad); return true; - default: - addError("PropertySetter.computeAggregationType returned " - + aggregationType); - return false; } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/NestedBasicPropertyIATest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/NestedBasicPropertyIATest.java new file mode 100644 index 000000000..e7f76b572 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/NestedBasicPropertyIATest.java @@ -0,0 +1,127 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.joran.spi.ElementPath; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.util.AggregationType; + +/** + * Tests {@link NestedBasicPropertyIA} without an XML parser: the action is + * driven directly with a bean on the interpretation context's object stack. + */ +public class NestedBasicPropertyIATest { + + private final Context context = new ContextBase(); + private final InterpretationContext ic = new InterpretationContext(context, null); + private final NestedBasicPropertyIA action = new NestedBasicPropertyIA(); + private final BasicPropertyBean bean = new BasicPropertyBean(); + + @Before + public void setUp() { + action.setContext(context); + } + + @Test + public void isNotApplicableWithoutParentObject() { + assertFalse(action.isApplicable(new ElementPath("bean/count"), new DummyAttributes(), ic)); + assertTrue(action.actionDataStack.isEmpty()); + } + + @Test + public void isNotApplicableToUnknownProperty() { + assertNotApplicable("unknown"); + } + + @Test + public void isNotApplicableToComplexProperty() { + assertNotApplicable("child"); + } + + @Test + public void isNotApplicableToComplexPropertyCollection() { + assertNotApplicable("friend"); + } + + @Test + public void isApplicableToBasicProperty() { + assertApplicable("count", AggregationType.AS_BASIC_PROPERTY); + } + + @Test + public void isApplicableToBasicPropertyCollection() { + assertApplicable("name", AggregationType.AS_BASIC_PROPERTY_COLLECTION); + } + + private void assertNotApplicable(String tagName) { + ic.pushObject(bean); + + assertFalse(action.isApplicable(new ElementPath("bean/" + tagName), new DummyAttributes(), ic)); + + assertTrue(action.actionDataStack.isEmpty()); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } + + private void assertApplicable(String tagName, AggregationType expectedType) { + ic.pushObject(bean); + + assertTrue(action.isApplicable(new ElementPath("bean/" + tagName), new DummyAttributes(), ic)); + + assertEquals(1, action.actionDataStack.size()); + IADataForBasicProperty data = action.actionDataStack.peek(); + assertSame(bean, data.parentBean.getObj()); + assertEquals(expectedType, data.aggregationType); + assertEquals(tagName, data.propertyName); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } + + /** A bean with one property of each {@link AggregationType}. */ + public static class BasicPropertyBean { + int count; + final List names = new ArrayList(); + BasicPropertyBean child; + final List friends = new ArrayList(); + + public void setCount(int count) { + this.count = count; + } + + public void addName(String name) { + names.add(name); + } + + public void setChild(BasicPropertyBean child) { + this.child = child; + } + + public void addFriend(BasicPropertyBean friend) { + friends.add(friend); + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/NestedComplexPropertyIATest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/NestedComplexPropertyIATest.java new file mode 100644 index 000000000..b70a15275 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/NestedComplexPropertyIATest.java @@ -0,0 +1,131 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.joran.spi.ElementPath; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.util.AggregationType; + +/** + * Tests {@link NestedComplexPropertyIA} without an XML parser: the action is + * driven directly with a bean on the interpretation context's object stack. + */ +public class NestedComplexPropertyIATest { + + private final Context context = new ContextBase(); + private final InterpretationContext ic = new InterpretationContext(context, null); + private final NestedComplexPropertyIA action = new NestedComplexPropertyIA(); + private final ComplexPropertyParent parent = new ComplexPropertyParent(); + + @Before + public void setUp() { + action.setContext(context); + } + + @Test + public void isNotApplicableWithoutParentObject() { + assertFalse(action.isApplicable(new ElementPath("parent/child"), new DummyAttributes(), ic)); + assertTrue(action.actionDataStack.isEmpty()); + } + + @Test + public void isNotApplicableToUnknownProperty() { + assertNotApplicable("unknown"); + } + + @Test + public void isNotApplicableToBasicProperty() { + assertNotApplicable("count"); + } + + @Test + public void isNotApplicableToBasicPropertyCollection() { + assertNotApplicable("name"); + } + + @Test + public void isApplicableToComplexProperty() { + assertApplicable("child", AggregationType.AS_COMPLEX_PROPERTY); + } + + @Test + public void isApplicableToComplexPropertyCollection() { + assertApplicable("friend", AggregationType.AS_COMPLEX_PROPERTY_COLLECTION); + } + + private void assertNotApplicable(String tagName) { + ic.pushObject(parent); + + assertFalse(action.isApplicable(new ElementPath("parent/" + tagName), new DummyAttributes(), ic)); + + assertTrue(action.actionDataStack.isEmpty()); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } + + private void assertApplicable(String tagName, AggregationType expectedType) { + ic.pushObject(parent); + + assertTrue(action.isApplicable(new ElementPath("parent/" + tagName), new DummyAttributes(), ic)); + + assertEquals(1, action.actionDataStack.size()); + IADataForComplexProperty data = action.actionDataStack.peek(); + assertSame(parent, data.parentBean.getObj()); + assertEquals(expectedType, data.getAggregationType()); + assertEquals(tagName, data.getComplexPropertyName()); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } + + /** A parent bean with one property of each {@link AggregationType}. */ + public static class ComplexPropertyParent { + int count; + final List names = new ArrayList(); + ComplexPropertyChild child; + final List friends = new ArrayList(); + + public void setCount(int count) { + this.count = count; + } + + public void addName(String name) { + names.add(name); + } + + public void setChild(ComplexPropertyChild child) { + this.child = child; + } + + public void addFriend(ComplexPropertyChild friend) { + friends.add(friend); + } + } + + /** A nested component. */ + public static class ComplexPropertyChild { + } +} From 91c260beb5f69efed591e833075aee706be4f3eb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:42:09 +0000 Subject: [PATCH 74/79] test: cover the core joran actions (property, include, nested IAs, ...) Plain-JVM tests that drive the actions directly (no XML parsing, which needs Robolectric's xmlpull) and bring DefinePropertyAction, IncludeAction, NestedBasicPropertyIA, NestedComplexPropertyIA, NewRuleAction, ParamAction, PropertyAction, ShutdownHookAction, StatusListenerAction and TimestampAction to full line and branch coverage: - DefinePropertyAction: a LifeCycle definer is started, a null value defines nothing, a foreign object on top of the stack is reported. - IncludeAction (recorder scripted via createRecorder): and wrappers are trimmed only when the closing element matches, a lone opening wrapper leaves nothing, null first/last events, the local name is used when the qualified name is empty, open and recording failures are reported (not when optional) and the stream is closed. - NestedBasicPropertyIA/NestedComplexPropertyIA: properties are set or added after substitution, nested components are instantiated (declared or implicit class), given the context and their parent, started unless @NoAutoStart; missing/uninstantiable classes, a foreign stack top and unexpected aggregation types are reported. - NewRuleAction: the rule reaches the interpreter's rule store; missing attributes and a failing store are reported; errors reset per element. - ParamAction, PropertyAction: missing name/value, the deprecated , unreadable file/resource (injected IOException), missing resource and every invalid attribute combination. - ShutdownHookAction: a declared hook is registered with the JVM (and removed), a bad class is reported and rethrown, a foreign stack top registers nothing. - StatusListenerAction: context-aware LifeCycle listeners are started, plain or rejected ones are not; missing class (with line number), bad class and foreign stack top are reported. - TimestampAction: context birth vs interpretation time as reference, missing key or date pattern set no property. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../action/DefinePropertyActionJvmTest.java | 154 +++++++ .../joran/action/IncludeActionJvmTest.java | 391 ++++++++++++++++++ .../action/NestedBasicPropertyIATest.java | 62 +++ .../action/NestedComplexPropertyIATest.java | 195 ++++++++- .../core/joran/action/NewRuleActionTest.java | 144 +++++++ .../core/joran/action/ParamActionTest.java | 115 ++++++ .../core/joran/action/PropertyActionTest.java | 152 +++++++ .../joran/action/ShutdownHookActionTest.java | 145 +++++++ .../action/StatusListenerActionTest.java | 219 ++++++++++ .../joran/action/TimestampActionTest.java | 137 ++++++ 10 files changed, 1710 insertions(+), 4 deletions(-) create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/action/DefinePropertyActionJvmTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/action/IncludeActionJvmTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/action/NewRuleActionTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/action/ParamActionTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/action/ShutdownHookActionTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/action/StatusListenerActionTest.java create mode 100644 logback-android/src/test/java/ch/qos/logback/core/joran/action/TimestampActionTest.java diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/DefinePropertyActionJvmTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/DefinePropertyActionJvmTest.java new file mode 100644 index 000000000..66f60c500 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/DefinePropertyActionJvmTest.java @@ -0,0 +1,154 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.List; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.PropertyDefinerBase; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.spi.LifeCycle; +import ch.qos.logback.core.status.Status; + +/** + * Plain-JVM tests (no Robolectric) of {@link DefinePropertyAction}, driving + * begin() and end() directly. The tests that parse configuration files are in + * {@link DefinePropertyActionTest}. + */ +public class DefinePropertyActionJvmTest { + + private final Context context = new ContextBase(); + private final InterpretationContext ic = new InterpretationContext(context, null); + private final DefinePropertyAction action = new DefinePropertyAction(); + + @Before + public void setUp() { + action.setContext(context); + } + + @Test + public void definerValueIsDefinedAsLocalProperty() throws Exception { + action.begin(ic, "define", defineAttributes("foo", AsLowerCasePropertyDefiner.class, null)); + + AsLowerCasePropertyDefiner definer = (AsLowerCasePropertyDefiner) ic.peekObject(); + assertSame(context, definer.getContext()); + definer.setValue("MONSTER"); + action.end(ic, "define"); + + assertEquals("monster", ic.getProperty("foo")); + assertNull(context.getProperty("foo")); + assertTrue(ic.isEmpty()); + } + + @Test + public void lifeCycleDefinerIsStartedBeforeItsValueIsRead() throws Exception { + action.begin(ic, "define", defineAttributes("foo", DefinePropertyActionDefiner.class, null)); + + DefinePropertyActionDefiner definer = (DefinePropertyActionDefiner) ic.peekObject(); + assertTrue(definer.isStarted()); + assertSame(context, definer.getContext()); + + definer.setValue("started"); + action.end(ic, "define"); + + assertEquals("started", ic.getProperty("foo")); + assertTrue(ic.isEmpty()); + } + + @Test + public void nullValueDefinesNoProperty() throws Exception { + action.begin(ic, "define", defineAttributes("foo", DefinePropertyActionDefiner.class, "context")); + + action.end(ic, "define"); + + assertTrue(ic.isEmpty()); + assertFalse(context.getCopyOfPropertyMap().containsKey("foo")); + assertNull(ic.getProperty("foo")); + } + + @Test + public void valueIsDefinedInTheRequestedScope() throws Exception { + action.begin(ic, "define", defineAttributes("foo", DefinePropertyActionDefiner.class, "context")); + ((DefinePropertyActionDefiner) ic.peekObject()).setValue("in context"); + + action.end(ic, "define"); + + assertEquals("in context", context.getProperty("foo")); + } + + @Test + public void foreignObjectOnTopOfTheStackIsReportedAndNoPropertyIsDefined() throws Exception { + action.begin(ic, "define", defineAttributes("foo", DefinePropertyActionDefiner.class, null)); + ((DefinePropertyActionDefiner) ic.peekObject()).setValue("ignored"); + Object foreign = new Object(); + ic.pushObject(foreign); + + action.end(ic, "define"); + + assertSame(foreign, ic.peekObject()); + assertEquals(2, ic.getObjectStack().size()); + assertNull(ic.getProperty("foo")); + List statuses = context.getStatusManager().getCopyOfStatusList(); + Status last = statuses.get(statuses.size() - 1); + assertEquals(Status.WARN, last.getLevel()); + assertEquals("The object at the of the stack is not the property definer for property named [foo] pushed earlier.", + last.getMessage()); + } + + private static DummyAttributes defineAttributes(String name, Class definerClass, String scope) { + DummyAttributes atts = new DummyAttributes(); + atts.setValue(Action.NAME_ATTRIBUTE, name); + atts.setValue(Action.CLASS_ATTRIBUTE, definerClass.getName()); + atts.setValue(Action.SCOPE_ATTRIBUTE, scope); + return atts; + } + + /** A property definer with a life cycle; its value is null until set. */ + public static class DefinePropertyActionDefiner extends PropertyDefinerBase implements LifeCycle { + private String value; + private boolean started; + + public void setValue(String value) { + this.value = value; + } + + public String getPropertyValue() { + return value; + } + + public void start() { + started = true; + } + + public void stop() { + started = false; + } + + public boolean isStarted() { + return started; + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/IncludeActionJvmTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/IncludeActionJvmTest.java new file mode 100644 index 000000000..895bb9068 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/IncludeActionJvmTest.java @@ -0,0 +1,391 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Constructor; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.xml.sax.InputSource; +import org.xml.sax.Locator; +import org.xml.sax.helpers.AttributesImpl; +import org.xml.sax.helpers.LocatorImpl; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.joran.event.EndEvent; +import ch.qos.logback.core.joran.event.SaxEvent; +import ch.qos.logback.core.joran.event.SaxEventRecorder; +import ch.qos.logback.core.joran.event.StartEvent; +import ch.qos.logback.core.joran.spi.ElementPath; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.joran.spi.Interpreter; +import ch.qos.logback.core.joran.spi.JoranException; +import ch.qos.logback.core.joran.spi.SimpleRuleStore; +import ch.qos.logback.core.status.Status; + +/** + * Plain-JVM tests (no Robolectric) of {@link IncludeAction#processInclude}: + * which recorded events are inserted into the event player, and how failures + * to open or record the included document are reported. The recorder is + * scripted instead of parsing XML, which needs Robolectric's xmlpull parser; + * the end-to-end tests are in {@link IncludeActionTest}. + */ +public class IncludeActionJvmTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private final Context context = new ContextBase(); + private final Interpreter interpreter = new Interpreter(context, new SimpleRuleStore(context), new ElementPath()); + private final InterpretationContext ic = interpreter.getInterpretationContext(); + private final ScriptedIncludeAction action = new ScriptedIncludeAction(); + private final TrackedStreamHandler handler = new TrackedStreamHandler(); + private URL url; + + /** The event player's list: the element being played, then the end of its parent. */ + private final List played = new ArrayList(); + + @Before + public void setUp() throws Exception { + action.setContext(context); + url = new URL(null, "inc:included.xml", handler); + SaxEventRecorder recorder = new SaxEventRecorder(context); + recorder.setDocumentLocator(new LocatorImpl()); + recorder.startElement("", "include", "include", new AttributesImpl()); + recorder.endElement("", "include", "include"); + recorder.endElement("", "x", "x"); + // an empty list leaves the player's current index at 0, i.e. at + interpreter.getEventPlayer().play(played); + played.addAll(recorder.getSaxEventList()); + } + + @Test + public void includedWrapperIsTrimmedAndTheContentInsertedAfterTheIncludeElement() throws JoranException { + action.script = new Script() { + public void record(SaxEventRecorder recorder) { + start(recorder, "included"); + start(recorder, "stack"); + end(recorder, "stack"); + end(recorder, "included"); + } + }; + + action.processInclude(ic, url); + + assertEquals(Arrays.asList("include", "/include", "stack", "/stack", "/x"), describe(played)); + assertSame(handler.opened, action.recordedStream); + assertSame(url, action.recordedUrl); + assertSame(context, action.recorder.getContext()); + assertTrue(handler.opened.closed); + } + + @Test + public void configurationWrapperIsTrimmed() throws JoranException { + action.script = new Script() { + public void record(SaxEventRecorder recorder) { + start(recorder, "configuration"); + start(recorder, "stack"); + end(recorder, "stack"); + end(recorder, "configuration"); + } + }; + + action.processInclude(ic, url); + + assertEquals(Arrays.asList("include", "/include", "stack", "/stack", "/x"), describe(played)); + } + + @Test + public void closingElementNotMatchingTheIncludedWrapperIsKept() throws JoranException { + action.script = new Script() { + public void record(SaxEventRecorder recorder) { + start(recorder, "included"); + start(recorder, "stack"); + end(recorder, "configuration"); + } + }; + + action.processInclude(ic, url); + + assertEquals(Arrays.asList("include", "/include", "stack", "/configuration", "/x"), describe(played)); + } + + @Test + public void closingElementNotMatchingTheConfigurationWrapperIsKept() throws JoranException { + action.script = new Script() { + public void record(SaxEventRecorder recorder) { + start(recorder, "configuration"); + start(recorder, "stack"); + end(recorder, "included"); + } + }; + + action.processInclude(ic, url); + + assertEquals(Arrays.asList("include", "/include", "stack", "/included", "/x"), describe(played)); + } + + @Test + public void documentWithoutWrapperIsInsertedAsIs() throws JoranException { + action.script = new Script() { + public void record(SaxEventRecorder recorder) { + start(recorder, "stack"); + end(recorder, "stack"); + } + }; + + action.processInclude(ic, url); + + assertEquals(Arrays.asList("include", "/include", "stack", "/stack", "/x"), describe(played)); + } + + @Test + public void wrapperWithoutClosingElementLeavesNothingToInsert() throws JoranException { + action.script = new Script() { + public void record(SaxEventRecorder recorder) { + start(recorder, "included"); + } + }; + + action.processInclude(ic, url); + + assertEquals(Arrays.asList("include", "/include", "/x"), describe(played)); + } + + @Test + public void missingFirstEventIsNotTrimmed() throws JoranException { + action.script = new Script() { + public void record(SaxEventRecorder recorder) { + recorder.getSaxEventList().add(null); + start(recorder, "stack"); + end(recorder, "included"); + } + }; + + action.processInclude(ic, url); + + assertEquals(Arrays.asList("include", "/include", null, "stack", "/included", "/x"), describe(played)); + } + + @Test + public void missingLastEventIsKeptAfterTrimmingTheOpeningWrapper() throws JoranException { + action.script = new Script() { + public void record(SaxEventRecorder recorder) { + start(recorder, "included"); + start(recorder, "stack"); + recorder.getSaxEventList().add(null); + } + }; + + action.processInclude(ic, url); + + assertEquals(Arrays.asList("include", "/include", "stack", null, "/x"), describe(played)); + } + + @Test + public void wrapperIsRecognizedByLocalNameWhenQualifiedNameIsEmpty() throws Exception { + final StartEvent start = newStartEvent("included"); + final EndEvent end = newEndEvent("included"); + action.script = new Script() { + public void record(SaxEventRecorder recorder) { + recorder.getSaxEventList().add(start); + start(recorder, "stack"); + recorder.getSaxEventList().add(end); + } + }; + + action.processInclude(ic, url); + + assertEquals(Arrays.asList("include", "/include", "stack", "/x"), describe(played)); + } + + @Test + public void recordingFailureIsReportedAndTheStreamClosed() throws JoranException { + final JoranException failure = new JoranException("broken document"); + action.script = new Script() { + public void record(SaxEventRecorder recorder) throws JoranException { + start(recorder, "included"); + throw failure; + } + }; + + action.processInclude(ic, url); + + assertEquals(Arrays.asList("include", "/include", "/x"), describe(played)); + assertTrue(handler.opened.closed); + Status error = findStatus("Failed processing [inc:included.xml]"); + assertEquals(Status.ERROR, error.getLevel()); + assertSame(failure, error.getThrowable()); + } + + @Test + public void recordingFailureIsIgnoredForAnOptionalInclude() throws Exception { + // begin() decides whether the include is optional; with a file it goes on to processInclude() + action.script = new Script() { + public void record(SaxEventRecorder recorder) throws JoranException { + throw new JoranException("broken document"); + } + }; + DummyAttributes atts = new DummyAttributes(); + atts.setValue("file", tmp.newFile("included.xml").getPath()); + atts.setValue("optional", "true"); + + action.begin(ic, "include", atts); + + assertEquals(Arrays.asList("include", "/include", "/x"), describe(played)); + for (Status s : context.getStatusManager().getCopyOfStatusList()) { + assertFalse(s.getMessage(), s.getMessage().startsWith("Failed processing")); + } + } + + @Test + public void unopenableUrlIsReportedAndNothingIsRecorded() throws Exception { + URL missing = new URL(tmp.getRoot().toURI().toURL(), "missing.xml"); + + action.processInclude(ic, missing); + + assertEquals(Arrays.asList("include", "/include", "/x"), describe(played)); + assertNull(action.recorder); + Status warning = findStatus("Failed to open [" + missing + "]"); + assertEquals(Status.WARN, warning.getLevel()); + assertTrue(warning.getThrowable() instanceof FileNotFoundException); + } + + private Status findStatus(String message) { + for (Status s : context.getStatusManager().getCopyOfStatusList()) { + if (message.equals(s.getMessage())) { + return s; + } + } + throw new AssertionError("no status [" + message + "] in " + context.getStatusManager().getCopyOfStatusList()); + } + + private static void start(SaxEventRecorder recorder, String name) { + recorder.startElement("", name, name, new AttributesImpl()); + } + + private static void end(SaxEventRecorder recorder, String name) { + recorder.endElement("", name, name); + } + + /** Describes events by qualified name, "/name" for end events. */ + private static List describe(List events) { + List names = new ArrayList(); + for (SaxEvent e : events) { + names.add(e == null ? null : (e instanceof EndEvent ? "/" : "") + e.getQName()); + } + return names; + } + + // SaxEventRecorder always fills in the qualified name, so events without one are built directly + + private static StartEvent newStartEvent(String localName) throws Exception { + Constructor c = StartEvent.class.getDeclaredConstructor(ElementPath.class, String.class, + String.class, String.class, org.xml.sax.Attributes.class, Locator.class); + c.setAccessible(true); + return c.newInstance(new ElementPath(localName), "", localName, "", new AttributesImpl(), new LocatorImpl()); + } + + private static EndEvent newEndEvent(String localName) throws Exception { + Constructor c = EndEvent.class.getDeclaredConstructor(String.class, String.class, String.class, + Locator.class); + c.setAccessible(true); + return c.newInstance("", localName, "", new LocatorImpl()); + } + + /** Stands in for parsing the included document. */ + interface Script { + void record(SaxEventRecorder recorder) throws JoranException; + } + + /** An include action whose recorder runs a script instead of parsing XML. */ + static class ScriptedIncludeAction extends IncludeAction { + Script script; + SaxEventRecorder recorder; + InputStream recordedStream; + URL recordedUrl; + + @Override + protected SaxEventRecorder createRecorder(InputStream in, URL url) { + recordedStream = in; + recordedUrl = url; + recorder = new SaxEventRecorder() { + @Override + public List recordEvents(InputSource inputSource) throws JoranException { + assertSame(recordedStream, inputSource.getByteStream()); + // as a parser would + setDocumentLocator(new LocatorImpl()); + script.record(this); + return getSaxEventList(); + } + }; + return recorder; + } + } + + /** Serves an empty document and remembers the stream it opened. */ + static class TrackedStreamHandler extends URLStreamHandler { + TrackedStream opened; + + @Override + protected URLConnection openConnection(URL u) { + return new URLConnection(u) { + @Override + public void connect() { + } + + @Override + public InputStream getInputStream() { + opened = new TrackedStream(); + return opened; + } + }; + } + } + + static class TrackedStream extends ByteArrayInputStream { + boolean closed; + + TrackedStream() { + super(new byte[0]); + } + + @Override + public void close() throws IOException { + closed = true; + super.close(); + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/NestedBasicPropertyIATest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/NestedBasicPropertyIATest.java index e7f76b572..17c07046d 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/action/NestedBasicPropertyIATest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/NestedBasicPropertyIATest.java @@ -17,10 +17,12 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import org.junit.Before; @@ -30,6 +32,8 @@ import ch.qos.logback.core.ContextBase; import ch.qos.logback.core.joran.spi.ElementPath; import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.joran.util.PropertySetter; +import ch.qos.logback.core.status.Status; import ch.qos.logback.core.util.AggregationType; /** @@ -79,6 +83,64 @@ public void isApplicableToBasicPropertyCollection() { assertApplicable("name", AggregationType.AS_BASIC_PROPERTY_COLLECTION); } + @Test + public void bodySetsTheBasicPropertyAfterVariableSubstitution() { + context.putProperty("tens", "4"); + ic.pushObject(bean); + assertTrue(action.isApplicable(new ElementPath("bean/count"), new DummyAttributes(), ic)); + + action.begin(ic, "count", new DummyAttributes()); + action.body(ic, "${tens}2"); + + assertEquals(42, bean.count); + assertEquals(1, action.actionDataStack.size()); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } + + @Test + public void bodyAddsToTheBasicPropertyCollection() { + ic.pushObject(bean); + assertTrue(action.isApplicable(new ElementPath("bean/name"), new DummyAttributes(), ic)); + + action.body(ic, "first"); + action.body(ic, "second"); + + assertEquals(Arrays.asList("first", "second"), bean.names); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } + + @Test + public void endPopsOnlyTheActionDataOfTheEndingElement() { + ic.pushObject(bean); + assertTrue(action.isApplicable(new ElementPath("bean/count"), new DummyAttributes(), ic)); + assertTrue(action.isApplicable(new ElementPath("bean/name"), new DummyAttributes(), ic)); + + action.end(ic, "name"); + + assertEquals(1, action.actionDataStack.size()); + assertEquals("count", action.actionDataStack.peek().propertyName); + assertSame(bean, ic.peekObject()); + + action.end(ic, "count"); + + assertTrue(action.actionDataStack.isEmpty()); + assertSame(bean, ic.peekObject()); + } + + @Test + public void bodyReportsActionDataOfAnUnexpectedAggregationType() { + action.actionDataStack.push(new IADataForBasicProperty(new PropertySetter(bean), + AggregationType.AS_COMPLEX_PROPERTY, "child")); + + action.body(ic, "value"); + + assertNull(bean.child); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(1, statuses.size()); + assertEquals(Status.ERROR, statuses.get(0).getLevel()); + assertEquals("Unexpected aggregationType AS_COMPLEX_PROPERTY", statuses.get(0).getMessage()); + } + private void assertNotApplicable(String tagName) { ic.pushObject(bean); diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/NestedComplexPropertyIATest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/NestedComplexPropertyIATest.java index b70a15275..9eab728a9 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/action/NestedComplexPropertyIATest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/NestedComplexPropertyIATest.java @@ -17,6 +17,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; @@ -30,6 +31,11 @@ import ch.qos.logback.core.ContextBase; import ch.qos.logback.core.joran.spi.ElementPath; import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.joran.spi.NoAutoStart; +import ch.qos.logback.core.joran.util.PropertySetter; +import ch.qos.logback.core.spi.ContextAwareBase; +import ch.qos.logback.core.spi.LifeCycle; +import ch.qos.logback.core.status.Status; import ch.qos.logback.core.util.AggregationType; /** @@ -79,6 +85,151 @@ public void isApplicableToComplexPropertyCollection() { assertApplicable("friend", AggregationType.AS_COMPLEX_PROPERTY_COLLECTION); } + @Test + public void declaredClassIsInstantiatedConfiguredStartedAndSetOnTheParent() { + context.putProperty("childClass", ComplexPropertyChild.class.getName()); + DummyAttributes atts = classAttribute("${childClass}"); + enter("child", atts); + + action.begin(ic, "child", atts); + + ComplexPropertyChild child = (ComplexPropertyChild) ic.peekObject(); + assertSame(context, child.getContext()); + assertFalse(child.isStarted()); + assertNull(parent.child); + + action.end(ic, "child"); + + assertSame(child, parent.child); + assertSame(parent, child.parent); + assertTrue(child.isStarted()); + assertSame(parent, ic.peekObject()); + assertTrue(action.actionDataStack.isEmpty()); + assertTrue(context.getStatusManager().getCopyOfStatusList().isEmpty()); + } + + @Test + public void componentMarkedNoAutoStartIsNotStarted() { + DummyAttributes atts = classAttribute(NoAutoStartChild.class.getName()); + enter("child", atts); + + action.begin(ic, "child", atts); + action.end(ic, "child"); + + assertTrue(parent.child instanceof NoAutoStartChild); + assertFalse(parent.child.isStarted()); + assertSame(parent, parent.child.parent); + } + + @Test + public void classIsGuessedFromTheAdderAndTheComponentIsAddedToTheCollection() { + DummyAttributes atts = new DummyAttributes(); + enter("friend", atts); + + action.begin(ic, "friend", atts); + Object friend = ic.peekObject(); + action.end(ic, "friend"); + + assertTrue(friend instanceof ComplexPropertyFriend); + assertEquals(1, parent.friends.size()); + assertSame(friend, parent.friends.get(0)); + assertSame(parent, ic.peekObject()); + assertStatus(Status.INFO, "Assuming default type [" + ComplexPropertyFriend.class.getName() + + "] for [friend] property"); + } + + @Test + public void missingClassIsReportedAndTheElementIsIgnored() { + DummyAttributes atts = new DummyAttributes(); + enter("helper", atts); + + action.begin(ic, "helper", atts); + + assertSame(parent, ic.peekObject()); + assertStatus(Status.ERROR, "Could not find an appropriate class for property [helper]"); + + action.end(ic, "helper"); + + assertNull(parent.helper); + assertSame(parent, ic.peekObject()); + assertTrue(action.actionDataStack.isEmpty()); + assertEquals(1, context.getStatusManager().getCount()); + } + + @Test + public void uninstantiableClassIsReportedAndTheElementIsIgnored() { + DummyAttributes atts = classAttribute("no.such.Component"); + enter("child", atts); + + action.begin(ic, "child", atts); + + assertSame(parent, ic.peekObject()); + Status error = assertStatus(Status.ERROR, + "Could not create component [child] of type [no.such.Component]"); + assertTrue(error.getThrowable() instanceof ClassNotFoundException); + + action.end(ic, "child"); + + assertNull(parent.child); + assertSame(parent, ic.peekObject()); + assertTrue(action.actionDataStack.isEmpty()); + assertEquals(1, context.getStatusManager().getCount()); + } + + @Test + public void endReportsAForeignObjectOnTopOfTheStack() { + DummyAttributes atts = classAttribute(ComplexPropertyChild.class.getName()); + enter("child", atts); + action.begin(ic, "child", atts); + Object foreign = new Object(); + ic.pushObject(foreign); + + action.end(ic, "child"); + + assertNull(parent.child); + assertSame(foreign, ic.peekObject()); + assertEquals(3, ic.getObjectStack().size()); + assertStatus(Status.ERROR, + "The object on the top the of the stack is not the component pushed earlier."); + } + + @Test + public void endReportsActionDataOfAnUnexpectedAggregationType() { + ic.pushObject(parent); + action.actionDataStack.push(new IADataForComplexProperty(new PropertySetter(parent), + AggregationType.AS_BASIC_PROPERTY, "child")); + action.begin(ic, "child", classAttribute(ComplexPropertyChild.class.getName())); + + action.end(ic, "child"); + + assertNull(parent.child); + assertSame(parent, ic.peekObject()); + assertTrue(action.actionDataStack.isEmpty()); + assertStatus(Status.ERROR, "Unexpected aggregationType AS_BASIC_PROPERTY"); + } + + private void enter(String tagName, DummyAttributes atts) { + ic.pushObject(parent); + assertTrue(action.isApplicable(new ElementPath("parent/" + tagName), atts, ic)); + } + + private static DummyAttributes classAttribute(String className) { + DummyAttributes atts = new DummyAttributes(); + atts.setValue(Action.CLASS_ATTRIBUTE, className); + return atts; + } + + private Status assertStatus(int level, String message) { + for (Status s : context.getStatusManager().getCopyOfStatusList()) { + if (message.equals(s.getMessage())) { + assertEquals(level, s.getLevel()); + return s; + } + } + throw new AssertionError("no status [" + message + "] in " + + context.getStatusManager().getCopyOfStatusList()); + } + private void assertNotApplicable(String tagName) { ic.pushObject(parent); @@ -106,7 +257,8 @@ public static class ComplexPropertyParent { int count; final List names = new ArrayList(); ComplexPropertyChild child; - final List friends = new ArrayList(); + final List friends = new ArrayList(); + ComplexPropertyHelper helper; public void setCount(int count) { this.count = count; @@ -120,12 +272,47 @@ public void setChild(ComplexPropertyChild child) { this.child = child; } - public void addFriend(ComplexPropertyChild friend) { + public void addFriend(ComplexPropertyFriend friend) { friends.add(friend); } + + public void setHelper(ComplexPropertyHelper helper) { + this.helper = helper; + } + } + + /** A context-aware, startable nested component that points back to its parent. */ + public static class ComplexPropertyChild extends ContextAwareBase implements LifeCycle { + ComplexPropertyParent parent; + private boolean started; + + public void setParent(ComplexPropertyParent parent) { + this.parent = parent; + } + + public void start() { + started = true; + } + + public void stop() { + started = false; + } + + public boolean isStarted() { + return started; + } + } + + /** A nested component that must not be started automatically. */ + @NoAutoStart + public static class NoAutoStartChild extends ComplexPropertyChild { + } + + /** A plain nested component: neither context-aware nor startable, no parent. */ + public static class ComplexPropertyFriend { } - /** A nested component. */ - public static class ComplexPropertyChild { + /** A property type Joran cannot instantiate without an explicit class. */ + public interface ComplexPropertyHelper { } } diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/NewRuleActionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/NewRuleActionTest.java new file mode 100644 index 000000000..9b8a4811a --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/NewRuleActionTest.java @@ -0,0 +1,144 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; + +import java.util.List; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.joran.action.ext.HelloAction; +import ch.qos.logback.core.joran.spi.ElementPath; +import ch.qos.logback.core.joran.spi.ElementSelector; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.joran.spi.Interpreter; +import ch.qos.logback.core.joran.spi.RuleStore; +import ch.qos.logback.core.joran.spi.SimpleRuleStore; +import ch.qos.logback.core.status.Status; + +/** + * Tests {@link NewRuleAction}. + */ +public class NewRuleActionTest { + + private final Context context = new ContextBase(); + private final RuleStore ruleStore = new SimpleRuleStore(context); + private final InterpretationContext ic = + new Interpreter(context, ruleStore, new ElementPath()).getInterpretationContext(); + private final NewRuleAction action = new NewRuleAction(); + + @Before + public void setUp() { + action.setContext(context); + } + + @Test + public void ruleIsAddedToTheInterpretersRuleStore() { + action.begin(ic, "newRule", newRuleAttributes("x/hello", HelloAction.class.getName())); + + assertFalse(action.inError); + List actions = ruleStore.matchActions(new ElementPath("x/hello")); + assertEquals(1, actions.size()); + assertTrue(actions.get(0) instanceof HelloAction); + assertSame(context, actions.get(0).getContext()); + assertOnlyStatus(Status.INFO, + "About to add new Joran parsing rule [x/hello," + HelloAction.class.getName() + "]."); + } + + @Test + public void missingPatternIsReported() { + action.begin(ic, "newRule", newRuleAttributes(null, HelloAction.class.getName())); + + assertTrue(action.inError); + assertOnlyStatus(Status.ERROR, "No 'pattern' attribute in "); + } + + @Test + public void missingActionClassIsReported() { + action.begin(ic, "newRule", newRuleAttributes("x/hello", "")); + + assertTrue(action.inError); + assertNull(ruleStore.matchActions(new ElementPath("x/hello"))); + assertOnlyStatus(Status.ERROR, "No 'actionClass' attribute in "); + } + + @Test + public void failureToAddTheRuleIsReported() throws Exception { + RuleStore failingStore = mock(RuleStore.class); + doThrow(new ClassNotFoundException(HelloAction.class.getName())) + .when(failingStore).addRule(any(ElementSelector.class), anyString()); + InterpretationContext failingIc = + new Interpreter(context, failingStore, new ElementPath()).getInterpretationContext(); + + action.begin(failingIc, "newRule", newRuleAttributes("x/hello", HelloAction.class.getName())); + + assertTrue(action.inError); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(2, statuses.size()); + Status error = statuses.get(1); + assertEquals(Status.ERROR, error.getLevel()); + assertEquals("Could not add new Joran parsing rule [x/hello," + HelloAction.class.getName() + "]", + error.getMessage()); + } + + @Test + public void previousErrorIsForgottenByTheNextElement() { + action.begin(ic, "newRule", newRuleAttributes(null, null)); + assertTrue(action.inError); + + action.begin(ic, "newRule", newRuleAttributes("x/hello", HelloAction.class.getName())); + + assertFalse(action.inError); + } + + @Test + public void endAndFinishDoNothing() { + ic.pushObject("top"); + + action.end(ic, "newRule"); + action.finish(ic); + + assertSame("top", ic.peekObject()); + assertEquals(1, ic.getObjectStack().size()); + assertEquals(0, context.getStatusManager().getCount()); + } + + private static DummyAttributes newRuleAttributes(String pattern, String actionClass) { + DummyAttributes atts = new DummyAttributes(); + atts.setValue(Action.PATTERN_ATTRIBUTE, pattern); + atts.setValue(Action.ACTION_CLASS_ATTRIBUTE, actionClass); + return atts; + } + + private void assertOnlyStatus(int level, String message) { + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(statuses.toString(), 1, statuses.size()); + assertEquals(level, statuses.get(0).getLevel()); + assertEquals(message, statuses.get(0).getMessage()); + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/ParamActionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/ParamActionTest.java new file mode 100644 index 000000000..91e104e24 --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/ParamActionTest.java @@ -0,0 +1,115 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.List; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.status.Status; + +/** + * Tests {@link ParamAction}. + */ +public class ParamActionTest { + + private final Context context = new ContextBase(); + private final InterpretationContext ic = new InterpretationContext(context, null); + private final ParamAction action = new ParamAction(); + private final ParamActionBean bean = new ParamActionBean(); + + @Before + public void setUp() { + action.setContext(context); + ic.pushObject(bean); + } + + @Test + public void parameterIsSetOnTheObjectOnTopOfTheStack() { + context.putProperty("prop", "greeting"); + context.putProperty("who", "world"); + + action.begin(ic, "param", paramAttributes("${prop}", " hello ${who} ")); + + assertEquals("hello world", bean.greeting); + assertFalse(action.inError); + assertSame(bean, ic.peekObject()); + assertEquals(0, context.getStatusManager().getCount()); + } + + @Test + public void missingNameIsReported() { + action.begin(ic, "param", paramAttributes(null, "hello")); + + assertTrue(action.inError); + assertNull(bean.greeting); + assertOnlyError(ParamAction.NO_NAME); + assertEquals("No name attribute in element", ParamAction.NO_NAME); + } + + @Test + public void missingValueIsReported() { + action.begin(ic, "param", paramAttributes("greeting", null)); + + assertTrue(action.inError); + assertNull(bean.greeting); + assertOnlyError(ParamAction.NO_VALUE); + assertEquals("No value attribute in element", ParamAction.NO_VALUE); + } + + @Test + public void endAndFinishDoNothing() { + action.end(ic, "param"); + action.finish(ic); + + assertSame(bean, ic.peekObject()); + assertEquals(1, ic.getObjectStack().size()); + assertEquals(0, context.getStatusManager().getCount()); + } + + private static DummyAttributes paramAttributes(String name, String value) { + DummyAttributes atts = new DummyAttributes(); + atts.setValue(Action.NAME_ATTRIBUTE, name); + atts.setValue(Action.VALUE_ATTRIBUTE, value); + return atts; + } + + private void assertOnlyError(String message) { + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(statuses.toString(), 1, statuses.size()); + assertEquals(Status.ERROR, statuses.get(0).getLevel()); + assertEquals(message, statuses.get(0).getMessage()); + } + + /** The object whose parameter is set. */ + public static class ParamActionBean { + String greeting; + + public void setGreeting(String greeting) { + this.greeting = greeting; + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/PropertyActionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/PropertyActionTest.java index 477e7d8fa..4fe6e62ea 100755 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/action/PropertyActionTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/PropertyActionTest.java @@ -16,8 +16,12 @@ package ch.qos.logback.core.joran.action; import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNull; +import static junit.framework.Assert.assertSame; import static junit.framework.Assert.assertTrue; +import java.io.IOException; +import java.io.InputStream; import java.util.Iterator; import org.junit.After; @@ -28,6 +32,7 @@ import ch.qos.logback.core.ContextBase; import ch.qos.logback.core.joran.spi.InterpretationContext; import ch.qos.logback.core.status.ErrorStatus; +import ch.qos.logback.core.status.Status; import ch.qos.logback.core.util.CoreTestConstants; import ch.qos.logback.core.util.StatusPrinter; @@ -149,6 +154,153 @@ public void testLoadNotPossible() { assertTrue(checkFileErrors()); } + @Test + public void deprecatedSubstitutionPropertyElementIsStillHonoredWithAWarning() { + atts.setValue("name", "v1"); + atts.setValue("value", "work"); + propertyAction.begin(ec, "substitutionProperty", atts); + assertEquals("work", ec.getProperty("v1")); + assertEquals(1, context.getStatusManager().getCount()); + Status warning = context.getStatusManager().getCopyOfStatusList().get(0); + assertEquals(Status.WARN, warning.getLevel()); + assertEquals("[substitutionProperty] element has been deprecated. Please use the [property] element instead.", + warning.getMessage()); + } + + @Test + public void propertyElementIsNotReportedAsDeprecated() { + atts.setValue("name", "v1"); + atts.setValue("value", "work"); + propertyAction.begin(ec, "property", atts); + assertEquals("work", ec.getProperty("v1")); + assertEquals(0, context.getStatusManager().getCount()); + } + + @Test + public void unreadableFileIsReported() { + IOException failure = new IOException("disk on fire"); + useActionFailingToLoadWith(failure); + String file = CoreTestConstants.TEST_DIR_PREFIX + "input/joran/propertyActionTest.properties"; + atts.setValue("file", file); + propertyAction.begin(ec, null, atts); + assertNull(ec.getProperty("v1")); + assertOnlyError("Could not read properties file [" + file + "].", failure); + } + + @Test + public void unreadableResourceIsReported() { + IOException failure = new IOException("jar on fire"); + useActionFailingToLoadWith(failure); + atts.setValue("resource", "asResource/joran/propertyActionTest.properties"); + propertyAction.begin(ec, null, atts); + assertNull(ec.getProperty("r1")); + assertOnlyError("Could not read resource file [asResource/joran/propertyActionTest.properties].", failure); + } + + @Test + public void missingResourceIsReported() { + context.putProperty("STEM", "asResource/joran"); + atts.setValue("resource", "${STEM}/no-such.properties"); + propertyAction.begin(ec, null, atts); + assertOnlyError("Could not find resource [asResource/joran/no-such.properties].", null); + } + + @Test + public void resourcePropertiesCanBeSetInContextScope() { + atts.setValue("resource", "asResource/joran/propertyActionTest.properties"); + atts.setValue("scope", "context"); + propertyAction.begin(ec, null, atts); + assertEquals("tata", context.getProperty("r1")); + assertEquals("toto", context.getProperty("r2")); + } + + @Test + public void fileWithNameIsInvalid() { + atts.setValue("file", "toto"); + atts.setValue("name", "v1"); + assertInvalidAttributes(); + } + + @Test + public void fileWithResourceIsInvalid() { + atts.setValue("file", "toto"); + atts.setValue("resource", "asResource/joran/propertyActionTest.properties"); + assertInvalidAttributes(); + assertNull(ec.getProperty("r1")); + } + + @Test + public void resourceWithNameIsInvalid() { + atts.setValue("resource", "asResource/joran/propertyActionTest.properties"); + atts.setValue("name", "v1"); + assertInvalidAttributes(); + assertNull(ec.getProperty("r1")); + } + + @Test + public void resourceWithValueIsInvalid() { + atts.setValue("resource", "asResource/joran/propertyActionTest.properties"); + atts.setValue("value", "work"); + assertInvalidAttributes(); + assertNull(ec.getProperty("r1")); + } + + @Test + public void nameValuePairWithFileIsInvalid() { + atts.setValue("name", "v1"); + atts.setValue("value", "work"); + atts.setValue("file", "toto"); + assertInvalidAttributes(); + assertNull(ec.getProperty("v1")); + } + + @Test + public void nameValuePairWithResourceIsInvalid() { + atts.setValue("name", "v1"); + atts.setValue("value", "work"); + atts.setValue("resource", "asResource/joran/propertyActionTest.properties"); + assertInvalidAttributes(); + assertNull(ec.getProperty("v1")); + assertNull(ec.getProperty("r1")); + } + + @Test + public void endAndFinishDoNothing() { + ec.pushObject("top"); + propertyAction.end(ec, "property"); + propertyAction.finish(ec); + assertEquals("top", ec.peekObject()); + assertEquals(1, ec.getObjectStack().size()); + assertEquals(0, context.getStatusManager().getCount()); + } + + /** Replaces the action under test by one whose reading of the opened stream fails. */ + private void useActionFailingToLoadWith(final IOException failure) { + propertyAction = new PropertyAction() { + @Override + void loadAndSetProperties(InterpretationContext ec, InputStream istream, ActionUtil.Scope scope) + throws IOException { + istream.close(); + throw failure; + } + }; + propertyAction.setContext(context); + } + + private void assertInvalidAttributes() { + propertyAction.begin(ec, null, atts); + assertEquals(1, context.getStatusManager().getCount()); + assertTrue(checkError()); + } + + private void assertOnlyError(String message, Throwable cause) { + assertEquals(1, context.getStatusManager().getCount()); + Status error = context.getStatusManager().getCopyOfStatusList().get(0); + assertEquals(Status.ERROR, error.getLevel()); + assertEquals(message, error.getMessage()); + assertSame(cause, error.getThrowable()); + } + private boolean checkError() { Iterator it = context.getStatusManager().getCopyOfStatusList().iterator(); ErrorStatus es = (ErrorStatus)it.next(); diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/ShutdownHookActionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/ShutdownHookActionTest.java new file mode 100644 index 000000000..a911bc88e --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/ShutdownHookActionTest.java @@ -0,0 +1,145 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.List; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.CoreConstants; +import ch.qos.logback.core.hook.DefaultShutdownHook; +import ch.qos.logback.core.hook.ShutdownHookBase; +import ch.qos.logback.core.joran.spi.ActionException; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.util.DynamicClassLoadingException; + +/** + * Tests {@link ShutdownHookAction}. + */ +public class ShutdownHookActionTest { + + private final Context context = new ContextBase(); + private final InterpretationContext ic = new InterpretationContext(context, null); + private final ShutdownHookAction action = new ShutdownHookAction(); + + @Before + public void setUp() { + context.setName("hookTest"); + action.setContext(context); + } + + @After + public void tearDown() { + // never leave a hook registered with the JVM + Object hookThread = context.getObject(CoreConstants.SHUTDOWN_HOOK_THREAD); + if (hookThread != null) { + Runtime.getRuntime().removeShutdownHook((Thread) hookThread); + } + } + + @Test + public void declaredHookIsInstantiatedAndRegisteredWithTheJvm() throws ActionException { + action.begin(ic, "shutdownHook", hookAttributes(ShutdownHookActionHook.class.getName())); + + ShutdownHookActionHook hook = (ShutdownHookActionHook) ic.peekObject(); + assertSame(hook, action.hook); + assertSame(context, hook.getContext()); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals(1, statuses.size()); + assertEquals("About to instantiate shutdown hook of type [" + ShutdownHookActionHook.class.getName() + "]", + statuses.get(0).getMessage()); + + action.end(ic, "shutdownHook"); + + assertTrue(ic.isEmpty()); + Thread hookThread = (Thread) context.getObject(CoreConstants.SHUTDOWN_HOOK_THREAD); + assertEquals("Logback shutdown hook [hookTest]", hookThread.getName()); + // removal succeeds only for a registered hook + assertTrue(Runtime.getRuntime().removeShutdownHook(hookThread)); + } + + @Test + public void defaultHookIsAssumedWithoutClassName() throws ActionException { + action.begin(ic, "shutdownHook", hookAttributes("")); + + assertTrue(ic.peekObject() instanceof DefaultShutdownHook); + List statuses = context.getStatusManager().getCopyOfStatusList(); + assertEquals("Assuming className [" + DefaultShutdownHook.class.getName() + "]", + statuses.get(0).getMessage()); + } + + @Test + public void uninstantiableHookIsReportedAndNothingIsRegistered() throws ActionException { + ActionException e = assertThrows(ActionException.class, + () -> action.begin(ic, "shutdownHook", hookAttributes("no.such.Hook"))); + + assertTrue(e.getCause() instanceof DynamicClassLoadingException); + assertTrue(ic.isEmpty()); + List statuses = context.getStatusManager().getCopyOfStatusList(); + Status error = statuses.get(statuses.size() - 1); + assertEquals(Status.ERROR, error.getLevel()); + assertEquals("Could not create a shutdown hook of type [no.such.Hook].", error.getMessage()); + assertSame(e.getCause(), error.getThrowable()); + int statusCount = statuses.size(); + + ic.pushObject("unrelated"); + action.end(ic, "shutdownHook"); + + assertSame("unrelated", ic.peekObject()); + assertNull(context.getObject(CoreConstants.SHUTDOWN_HOOK_THREAD)); + assertEquals(statusCount, context.getStatusManager().getCount()); + } + + @Test + public void foreignObjectOnTopOfTheStackIsReportedAndNothingIsRegistered() throws ActionException { + action.begin(ic, "shutdownHook", hookAttributes(ShutdownHookActionHook.class.getName())); + Object foreign = new Object(); + ic.pushObject(foreign); + + action.end(ic, "shutdownHook"); + + assertSame(foreign, ic.peekObject()); + assertEquals(2, ic.getObjectStack().size()); + assertNull(context.getObject(CoreConstants.SHUTDOWN_HOOK_THREAD)); + List statuses = context.getStatusManager().getCopyOfStatusList(); + Status warning = statuses.get(statuses.size() - 1); + assertEquals(Status.WARN, warning.getLevel()); + assertEquals("The object at the of the stack is not the hook pushed earlier.", warning.getMessage()); + } + + private static DummyAttributes hookAttributes(String className) { + DummyAttributes atts = new DummyAttributes(); + atts.setValue(Action.CLASS_ATTRIBUTE, className); + return atts; + } + + /** A shutdown hook that does nothing when run. */ + public static class ShutdownHookActionHook extends ShutdownHookBase { + public void run() { + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/StatusListenerActionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/StatusListenerActionTest.java new file mode 100644 index 000000000..d6583e62f --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/StatusListenerActionTest.java @@ -0,0 +1,219 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.List; + +import org.junit.Before; +import org.junit.Test; +import org.xml.sax.helpers.LocatorImpl; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.joran.spi.ActionException; +import ch.qos.logback.core.joran.spi.ElementPath; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.joran.spi.Interpreter; +import ch.qos.logback.core.joran.spi.SimpleRuleStore; +import ch.qos.logback.core.spi.ContextAwareBase; +import ch.qos.logback.core.spi.LifeCycle; +import ch.qos.logback.core.status.OnConsoleStatusListener; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusListener; +import ch.qos.logback.core.status.StatusManager; +import ch.qos.logback.core.util.DynamicClassLoadingException; + +/** + * Tests {@link StatusListenerAction}. + */ +public class StatusListenerActionTest { + + private final Context context = new ContextBase(); + private final StatusManager statusManager = context.getStatusManager(); + private final Interpreter interpreter = new Interpreter(context, new SimpleRuleStore(context), new ElementPath()); + private final InterpretationContext ic = interpreter.getInterpretationContext(); + private final StatusListenerAction action = new StatusListenerAction(); + + @Before + public void setUp() { + action.setContext(context); + } + + @Test + public void contextAwareLifeCycleListenerIsAddedConfiguredAndStarted() throws ActionException { + action.begin(ic, "statusListener", listenerAttributes(StartableStatusListenerActionListener.class.getName())); + + StartableStatusListenerActionListener listener = (StartableStatusListenerActionListener) ic.peekObject(); + assertTrue(statusManager.getCopyOfStatusListenerList().contains(listener)); + assertSame(context, listener.getContext()); + assertFalse(listener.isStarted()); + assertStatus(Status.INFO, + "Added status listener of type [" + StartableStatusListenerActionListener.class.getName() + "]"); + + action.end(ic, "statusListener"); + + assertTrue(listener.isStarted()); + assertTrue(ic.isEmpty()); + } + + @Test + public void plainListenerIsAddedAndPopped() throws ActionException { + action.begin(ic, "statusListener", listenerAttributes(PlainStatusListenerActionListener.class.getName())); + + Object listener = ic.peekObject(); + assertTrue(listener instanceof PlainStatusListenerActionListener); + assertTrue(statusManager.getCopyOfStatusListenerList().contains(listener)); + + action.end(ic, "statusListener"); + + assertTrue(ic.isEmpty()); + assertTrue(statusManager.getCopyOfStatusListenerList().contains(listener)); + } + + @Test + public void listenerRejectedByTheStatusManagerIsNotStarted() throws ActionException { + OnConsoleStatusListener present = new OnConsoleStatusListener(); + statusManager.add(present); + + action.begin(ic, "statusListener", listenerAttributes(OnConsoleStatusListener.class.getName())); + OnConsoleStatusListener rejected = (OnConsoleStatusListener) ic.peekObject(); + action.end(ic, "statusListener"); + + assertFalse(rejected.isStarted()); + assertTrue(ic.isEmpty()); + List listeners = statusManager.getCopyOfStatusListenerList(); + assertEquals(1, listeners.size()); + assertSame(present, listeners.get(0)); + } + + @Test + public void listenerNotKnownToBeAddedIsNotStarted() { + // end() without a begin() that added the listener + StartableStatusListenerActionListener listener = new StartableStatusListenerActionListener(); + action.statusListener = listener; + ic.pushObject(listener); + + action.end(ic, "statusListener"); + + assertFalse(listener.isStarted()); + assertTrue(ic.isEmpty()); + } + + @Test + public void missingClassNameIsReportedWithTheLineNumber() throws ActionException { + LocatorImpl locator = new LocatorImpl(); + locator.setLineNumber(12); + interpreter.setDocumentLocator(locator); + + action.begin(ic, "statusListener", listenerAttributes("")); + + assertTrue(action.inError); + assertTrue(ic.isEmpty()); + assertTrue(statusManager.getCopyOfStatusListenerList().isEmpty()); + assertStatus(Status.ERROR, "Missing class name for statusListener. Near [statusListener] line 12"); + int statusCount = statusManager.getCount(); + + ic.pushObject("unrelated"); + action.end(ic, "statusListener"); + + assertSame("unrelated", ic.peekObject()); + assertEquals(statusCount, statusManager.getCount()); + } + + @Test + public void uninstantiableListenerIsReportedAndRethrown() { + ActionException e = assertThrows(ActionException.class, + () -> action.begin(ic, "statusListener", listenerAttributes("no.such.Listener"))); + + assertTrue(e.getCause() instanceof DynamicClassLoadingException); + assertTrue(action.inError); + assertTrue(ic.isEmpty()); + Status error = assertStatus(Status.ERROR, "Could not create an StatusListener of type [no.such.Listener]."); + assertSame(e.getCause(), error.getThrowable()); + } + + @Test + public void foreignObjectOnTopOfTheStackIsReported() throws ActionException { + action.begin(ic, "statusListener", listenerAttributes(PlainStatusListenerActionListener.class.getName())); + Object foreign = new Object(); + ic.pushObject(foreign); + + action.end(ic, "statusListener"); + + assertSame(foreign, ic.peekObject()); + assertEquals(2, ic.getObjectStack().size()); + assertStatus(Status.WARN, "The object at the of the stack is not the statusListener pushed earlier."); + } + + @Test + public void finishDoesNothing() { + ic.pushObject("top"); + + action.finish(ic); + + assertSame("top", ic.peekObject()); + assertEquals(0, statusManager.getCount()); + } + + private static DummyAttributes listenerAttributes(String className) { + DummyAttributes atts = new DummyAttributes(); + atts.setValue(Action.CLASS_ATTRIBUTE, className); + return atts; + } + + private Status assertStatus(int level, String message) { + for (Status s : statusManager.getCopyOfStatusList()) { + if (message.equals(s.getMessage())) { + assertEquals(level, s.getLevel()); + return s; + } + } + throw new AssertionError("no status [" + message + "] in " + statusManager.getCopyOfStatusList()); + } + + /** A status listener that is neither context-aware nor startable. */ + public static class PlainStatusListenerActionListener implements StatusListener { + public void addStatusEvent(Status status) { + } + } + + /** A context-aware, startable status listener. */ + public static class StartableStatusListenerActionListener extends ContextAwareBase + implements StatusListener, LifeCycle { + private boolean started; + + public void addStatusEvent(Status status) { + } + + public void start() { + started = true; + } + + public void stop() { + started = false; + } + + public boolean isStarted() { + return started; + } + } +} diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/TimestampActionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/TimestampActionTest.java new file mode 100644 index 000000000..63b2f29aa --- /dev/null +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/TimestampActionTest.java @@ -0,0 +1,137 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.joran.action; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; + +import ch.qos.logback.core.Context; +import ch.qos.logback.core.ContextBase; +import ch.qos.logback.core.joran.spi.ActionException; +import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.status.Status; + +/** + * Tests {@link TimestampAction}. + */ +public class TimestampActionTest { + + /** 2001-07-01T12:00:00.123Z: in 2001 in every time zone, and long before "now". */ + private static final long BIRTH_TIME = 993_988_800_123L; + private static final String PATTERN = "yyyy.SSS"; + + private final Context context = new ContextBase() { + @Override + public long getBirthTime() { + return BIRTH_TIME; + } + }; + private final InterpretationContext ic = new InterpretationContext(context, null); + private final TimestampAction action = new TimestampAction(); + + @Before + public void setUp() { + action.setContext(context); + } + + @Test + public void contextBirthIsUsedAsTimeReference() throws ActionException { + action.begin(ic, "timestamp", timestampAttributes("birth", PATTERN, "ContextBirth", null)); + + String expected = new SimpleDateFormat(PATTERN).format(new Date(BIRTH_TIME)); + assertTrue(expected, expected.startsWith("2001.")); + assertEquals(expected, ic.getProperty("birth")); + assertNull(context.getProperty("birth")); + List messages = messages(); + assertTrue(messages.contains("Using context birth as time reference.")); + assertTrue(messages.contains("Adding property to the context with key=\"birth\" and value=\"" + + expected + "\" to the LOCAL scope")); + } + + @Test + public void interpretationTimeIsUsedAsTimeReferenceByDefault() throws ActionException { + action.begin(ic, "timestamp", timestampAttributes("now", PATTERN, null, "context")); + + String value = context.getProperty("now"); + assertNotNull(value); + assertNotEquals(new SimpleDateFormat(PATTERN).format(new Date(BIRTH_TIME)), value); + List messages = messages(); + assertTrue(messages.contains("Using current interpretation time, i.e. now, as time reference.")); + assertFalse(messages.contains("Using context birth as time reference.")); + } + + @Test + public void missingKeyIsReportedAndNoPropertyIsAdded() throws ActionException { + action.begin(ic, "timestamp", timestampAttributes("", PATTERN, null, null)); + + assertTrue(action.inError); + assertOnlyErrors("Attribute named [key] cannot be empty"); + assertTrue(ic.getCopyOfPropertyMap().isEmpty()); + } + + @Test + public void missingDatePatternIsReportedAndNoPropertyIsAdded() throws ActionException { + action.begin(ic, "timestamp", timestampAttributes("stamp", null, null, null)); + + assertTrue(action.inError); + assertOnlyErrors("Attribute named [datePattern] cannot be empty"); + assertNull(ic.getProperty("stamp")); + } + + private static DummyAttributes timestampAttributes(String key, String datePattern, + String timeReference, String scope) { + DummyAttributes atts = new DummyAttributes(); + atts.setValue(Action.KEY_ATTRIBUTE, key); + atts.setValue(TimestampAction.DATE_PATTERN_ATTRIBUTE, datePattern); + atts.setValue(TimestampAction.TIME_REFERENCE_ATTRIBUTE, timeReference); + atts.setValue(Action.SCOPE_ATTRIBUTE, scope); + return atts; + } + + private List messages() { + List messages = new ArrayList(); + for (Status s : context.getStatusManager().getCopyOfStatusList()) { + messages.add(s.getMessage()); + } + return messages; + } + + private void assertOnlyErrors(String... expected) { + List errors = new ArrayList(); + for (Status s : context.getStatusManager().getCopyOfStatusList()) { + if (s.getLevel() == Status.ERROR) { + errors.add(s.getMessage()); + } + } + assertEquals(Arrays.asList(expected), errors); + for (String message : messages()) { + assertFalse(message, message.startsWith("Adding property")); + } + } +} From 0548e22e66bfcd78793e69e540e5349344c82673 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:28:21 +0000 Subject: [PATCH 75/79] test: make the timestamp test locale-independent; pin joran action resets TimestampActionTest built its expected value with the default locale, but CachingDateFormatter formats in Locale.US, so the test failed under a Thai (Buddhist calendar) or Arabic (non-ASCII digits) default locale. It now expects the fixed "2001.123", and the "now" test checks that the value is the time of the call rather than only differing from the birth time. Also pin behavior that no test pinned before: DefinePropertyAction, ShutdownHookAction and StatusListenerAction forget an earlier element's error on the next begin(), and IncludeAction adds an included file to the configuration watch list. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../action/DefinePropertyActionJvmTest.java | 28 +++++++++++++++++++ .../joran/action/IncludeActionJvmTest.java | 27 ++++++++++++++++++ .../joran/action/ShutdownHookActionTest.java | 13 +++++++++ .../action/StatusListenerActionTest.java | 14 ++++++++++ .../joran/action/TimestampActionTest.java | 25 +++++++++++------ 5 files changed, 99 insertions(+), 8 deletions(-) diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/DefinePropertyActionJvmTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/DefinePropertyActionJvmTest.java index 66f60c500..130f9e0c4 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/action/DefinePropertyActionJvmTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/DefinePropertyActionJvmTest.java @@ -29,7 +29,10 @@ import ch.qos.logback.core.Context; import ch.qos.logback.core.ContextBase; import ch.qos.logback.core.PropertyDefinerBase; +import ch.qos.logback.core.joran.spi.ElementPath; import ch.qos.logback.core.joran.spi.InterpretationContext; +import ch.qos.logback.core.joran.spi.Interpreter; +import ch.qos.logback.core.joran.spi.SimpleRuleStore; import ch.qos.logback.core.spi.LifeCycle; import ch.qos.logback.core.status.Status; @@ -99,6 +102,31 @@ public void valueIsDefinedInTheRequestedScope() throws Exception { assertEquals("in context", context.getProperty("foo")); } + @Test + public void previousErrorIsForgottenByTheNextElement() throws Exception { + // the error message gives the line number, which needs an interpreter + InterpretationContext withInterpreter = new Interpreter(context, new SimpleRuleStore(context), new ElementPath()) + .getInterpretationContext(); + action.begin(withInterpreter, "define", defineAttributes("", DefinePropertyActionDefiner.class, null)); + assertTrue(action.inError); + assertTrue(withInterpreter.isEmpty()); + assertEquals(Status.ERROR, lastStatus().getLevel()); + assertEquals("Missing property name for property definer. Near [define] line -1", lastStatus().getMessage()); + + action.begin(withInterpreter, "define", defineAttributes("foo", DefinePropertyActionDefiner.class, null)); + ((DefinePropertyActionDefiner) withInterpreter.peekObject()).setValue("defined"); + action.end(withInterpreter, "define"); + + assertFalse(action.inError); + assertEquals("defined", withInterpreter.getProperty("foo")); + assertTrue(withInterpreter.isEmpty()); + } + + private Status lastStatus() { + List statuses = context.getStatusManager().getCopyOfStatusList(); + return statuses.get(statuses.size() - 1); + } + @Test public void foreignObjectOnTopOfTheStackIsReportedAndNoPropertyIsDefined() throws Exception { action.begin(ic, "define", defineAttributes("foo", DefinePropertyActionDefiner.class, null)); diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/IncludeActionJvmTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/IncludeActionJvmTest.java index 895bb9068..e79c22065 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/action/IncludeActionJvmTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/IncludeActionJvmTest.java @@ -22,6 +22,7 @@ import static org.junit.Assert.assertTrue; import java.io.ByteArrayInputStream; +import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; @@ -31,6 +32,7 @@ import java.net.URLStreamHandler; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import org.junit.Before; @@ -48,11 +50,13 @@ import ch.qos.logback.core.joran.event.SaxEvent; import ch.qos.logback.core.joran.event.SaxEventRecorder; import ch.qos.logback.core.joran.event.StartEvent; +import ch.qos.logback.core.joran.spi.ConfigurationWatchList; import ch.qos.logback.core.joran.spi.ElementPath; import ch.qos.logback.core.joran.spi.InterpretationContext; import ch.qos.logback.core.joran.spi.Interpreter; import ch.qos.logback.core.joran.spi.JoranException; import ch.qos.logback.core.joran.spi.SimpleRuleStore; +import ch.qos.logback.core.joran.util.ConfigurationWatchListUtil; import ch.qos.logback.core.status.Status; /** @@ -111,6 +115,29 @@ public void record(SaxEventRecorder recorder) { assertTrue(handler.opened.closed); } + @Test + public void includedFileIsAddedToTheConfigurationWatchList() throws Exception { + ConfigurationWatchList watchList = new ConfigurationWatchList(); + watchList.setContext(context); + ConfigurationWatchListUtil.registerConfigurationWatchList(context, watchList); + File included = tmp.newFile("watched.xml"); + URL fileUrl = included.toURI().toURL(); + action.script = new Script() { + public void record(SaxEventRecorder recorder) { + start(recorder, "included"); + start(recorder, "stack"); + end(recorder, "stack"); + end(recorder, "included"); + } + }; + + action.processInclude(ic, fileUrl); + + assertEquals(Collections.singletonList(included.getAbsoluteFile()), watchList.getCopyOfFileWatchList()); + assertEquals(Status.INFO, findStatus("Adding [" + fileUrl + "] to configuration watch list.").getLevel()); + assertEquals(Arrays.asList("include", "/include", "stack", "/stack", "/x"), describe(played)); + } + @Test public void configurationWrapperIsTrimmed() throws JoranException { action.script = new Script() { diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/ShutdownHookActionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/ShutdownHookActionTest.java index a911bc88e..0ec7cce60 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/action/ShutdownHookActionTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/ShutdownHookActionTest.java @@ -114,6 +114,19 @@ public void uninstantiableHookIsReportedAndNothingIsRegistered() throws ActionEx assertEquals(statusCount, context.getStatusManager().getCount()); } + @Test + public void previousErrorIsForgottenByTheNextElement() throws ActionException { + assertThrows(ActionException.class, + () -> action.begin(ic, "shutdownHook", hookAttributes("no.such.Hook"))); + + action.begin(ic, "shutdownHook", hookAttributes(ShutdownHookActionHook.class.getName())); + action.end(ic, "shutdownHook"); + + assertTrue(ic.isEmpty()); + Thread hookThread = (Thread) context.getObject(CoreConstants.SHUTDOWN_HOOK_THREAD); + assertTrue(Runtime.getRuntime().removeShutdownHook(hookThread)); + } + @Test public void foreignObjectOnTopOfTheStackIsReportedAndNothingIsRegistered() throws ActionException { action.begin(ic, "shutdownHook", hookAttributes(ShutdownHookActionHook.class.getName())); diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/StatusListenerActionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/StatusListenerActionTest.java index d6583e62f..03119de51 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/action/StatusListenerActionTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/StatusListenerActionTest.java @@ -139,6 +139,20 @@ public void missingClassNameIsReportedWithTheLineNumber() throws ActionException assertEquals(statusCount, statusManager.getCount()); } + @Test + public void previousErrorIsForgottenByTheNextElement() throws ActionException { + action.begin(ic, "statusListener", listenerAttributes("")); + assertTrue(action.inError); + + action.begin(ic, "statusListener", listenerAttributes(StartableStatusListenerActionListener.class.getName())); + StartableStatusListenerActionListener listener = (StartableStatusListenerActionListener) ic.peekObject(); + action.end(ic, "statusListener"); + + assertFalse(action.inError); + assertTrue(listener.isStarted()); + assertTrue(ic.isEmpty()); + } + @Test public void uninstantiableListenerIsReportedAndRethrown() { ActionException e = assertThrows(ActionException.class, diff --git a/logback-android/src/test/java/ch/qos/logback/core/joran/action/TimestampActionTest.java b/logback-android/src/test/java/ch/qos/logback/core/joran/action/TimestampActionTest.java index 63b2f29aa..c13e221fb 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/joran/action/TimestampActionTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/joran/action/TimestampActionTest.java @@ -17,8 +17,6 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -26,7 +24,10 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Date; +import java.util.HashSet; import java.util.List; +import java.util.Locale; +import java.util.Set; import org.junit.Before; import org.junit.Test; @@ -44,7 +45,9 @@ public class TimestampActionTest { /** 2001-07-01T12:00:00.123Z: in 2001 in every time zone, and long before "now". */ private static final long BIRTH_TIME = 993_988_800_123L; + /** Year and milliseconds: the same for BIRTH_TIME in every time zone. */ private static final String PATTERN = "yyyy.SSS"; + private static final String BIRTH_TIME_FORMATTED = "2001.123"; private final Context context = new ContextBase() { @Override @@ -64,23 +67,29 @@ public void setUp() { public void contextBirthIsUsedAsTimeReference() throws ActionException { action.begin(ic, "timestamp", timestampAttributes("birth", PATTERN, "ContextBirth", null)); - String expected = new SimpleDateFormat(PATTERN).format(new Date(BIRTH_TIME)); - assertTrue(expected, expected.startsWith("2001.")); - assertEquals(expected, ic.getProperty("birth")); + // the date is formatted in the US locale (Gregorian calendar, ASCII digits) whatever the default one + assertEquals(BIRTH_TIME_FORMATTED, ic.getProperty("birth")); assertNull(context.getProperty("birth")); List messages = messages(); assertTrue(messages.contains("Using context birth as time reference.")); assertTrue(messages.contains("Adding property to the context with key=\"birth\" and value=\"" - + expected + "\" to the LOCAL scope")); + + BIRTH_TIME_FORMATTED + "\" to the LOCAL scope")); } @Test public void interpretationTimeIsUsedAsTimeReferenceByDefault() throws ActionException { + long before = System.currentTimeMillis(); action.begin(ic, "timestamp", timestampAttributes("now", PATTERN, null, "context")); + long after = System.currentTimeMillis(); + // the value is the time of the call, formatted like the action does (US locale, default time zone) + SimpleDateFormat format = new SimpleDateFormat(PATTERN, Locale.US); + Set possibleValues = new HashSet(); + for (long t = Math.min(before, after); t <= Math.max(before, after); t++) { + possibleValues.add(format.format(new Date(t))); + } String value = context.getProperty("now"); - assertNotNull(value); - assertNotEquals(new SimpleDateFormat(PATTERN).format(new Date(BIRTH_TIME)), value); + assertTrue(value + " not in " + possibleValues, possibleValues.contains(value)); List messages = messages(); assertTrue(messages.contains("Using current interpretation time, i.e. now, as time reference.")); assertFalse(messages.contains("Using context birth as time reference.")); From 22c2df01e37361fc08bcf8d80a33e05dbc9cf0d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:40:02 +0000 Subject: [PATCH 76/79] test: cover the unreadable-active-file branch on any OS and user TimeBasedFileNamingAndTriggeringPolicyBase.start() ignores the active file's modification time when the file can't be read. The only test of that branch withdrew read permission with setReadable(false), which has no effect for root or on Windows, so there it was skipped and the coverage gate would fail. start() now gets the file from a package-private newActiveFile(), and the test supplies a File that reports it can't be read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- ...asedFileNamingAndTriggeringPolicyBase.java | 12 +++++- ...FileNamingAndTriggeringPolicyBaseTest.java | 40 ++++++++++++------- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/logback-android/src/main/java/ch/qos/logback/core/rolling/TimeBasedFileNamingAndTriggeringPolicyBase.java b/logback-android/src/main/java/ch/qos/logback/core/rolling/TimeBasedFileNamingAndTriggeringPolicyBase.java index 25d3ae884..8f5e9d63f 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/rolling/TimeBasedFileNamingAndTriggeringPolicyBase.java +++ b/logback-android/src/main/java/ch/qos/logback/core/rolling/TimeBasedFileNamingAndTriggeringPolicyBase.java @@ -78,7 +78,7 @@ public void start() { setDateInCurrentPeriod(new Date(getCurrentTime())); if (tbrp.getParentsRawFileProperty() != null) { - File currentFile = new File(tbrp.getParentsRawFileProperty()); + File currentFile = newActiveFile(tbrp.getParentsRawFileProperty()); if (currentFile.exists() && currentFile.canRead()) { setDateInCurrentPeriod(new Date(currentFile.lastModified())); } @@ -93,6 +93,16 @@ public void start() { computeNextCheck(); } + /** + * The file the parent appender writes to, whose modification time sets the + * initial period when it can be read. Package-private so that a unit test + * can supply an unreadable file: file permissions can't make a file + * unreadable for a privileged user, or at all on some platforms. + */ + File newActiveFile(String fileName) { + return new File(fileName); + } + @Override public void stop() { started = false; diff --git a/logback-android/src/test/java/ch/qos/logback/core/rolling/TimeBasedFileNamingAndTriggeringPolicyBaseTest.java b/logback-android/src/test/java/ch/qos/logback/core/rolling/TimeBasedFileNamingAndTriggeringPolicyBaseTest.java index 69de3e2c8..7c022eced 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/rolling/TimeBasedFileNamingAndTriggeringPolicyBaseTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/rolling/TimeBasedFileNamingAndTriggeringPolicyBaseTest.java @@ -32,7 +32,6 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; -import static org.junit.Assume.assumeTrue; /** * @author Ceki Gücü @@ -166,19 +165,30 @@ public void unreadableActiveFileDoesNotDetermineInitialPeriod() throws IOExcepti // Friday January 7th 12:00:00 UTC 2000 File activeFile = tmp.newFile("active.log"); assertTrue(activeFile.setLastModified(947246400000L)); - // read permissions cannot be withdrawn on some platforms, nor from a privileged user - assumeTrue(activeFile.setReadable(false, false) && !activeFile.canRead()); - try { - rfa.setFile(activeFile.getAbsolutePath()); - tbrp.setFileNamePattern("foo-%d{yyyy-MM-dd, UTC}.log"); - // Tuesday December 20th 16:59:01 UTC 2011 - timeBasedFNATP.setCurrentTime(1324400341553L); - - tbrp.start(); - - assertEquals("foo-2011-12-20.log", timeBasedFNATP.getCurrentPeriodsFileNameWithoutCompressionSuffix()); - } finally { - activeFile.setReadable(true, false); - } + // file permissions can't make a file unreadable for a privileged user, or + // at all on some platforms, so hand the policy a file that reports it + // can't be read + DefaultTimeBasedFileNamingAndTriggeringPolicy fnatp = new DefaultTimeBasedFileNamingAndTriggeringPolicy() { + @Override + File newActiveFile(String fileName) { + return new File(fileName) { + @Override + public boolean canRead() { + return false; + } + }; + } + }; + fnatp.setContext(context); + tbrp.setTimeBasedFileNamingAndTriggeringPolicy(fnatp); + fnatp.setTimeBasedRollingPolicy(tbrp); + rfa.setFile(activeFile.getAbsolutePath()); + tbrp.setFileNamePattern("foo-%d{yyyy-MM-dd, UTC}.log"); + // Tuesday December 20th 16:59:01 UTC 2011 + fnatp.setCurrentTime(1324400341553L); + + tbrp.start(); + + assertEquals("foo-2011-12-20.log", fnatp.getCurrentPeriodsFileNameWithoutCompressionSuffix()); } } From 77934f6e20a86b8a7a99f4dc962e3b18076dab56 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:00:50 +0000 Subject: [PATCH 77/79] docs: note SimpleSocketServer.doMain's divergence from upstream Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../java/ch/qos/logback/classic/net/SimpleSocketServer.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/logback-android/src/main/java/ch/qos/logback/classic/net/SimpleSocketServer.java b/logback-android/src/main/java/ch/qos/logback/classic/net/SimpleSocketServer.java index 89c8a2104..d4e769876 100755 --- a/logback-android/src/main/java/ch/qos/logback/classic/net/SimpleSocketServer.java +++ b/logback-android/src/main/java/ch/qos/logback/classic/net/SimpleSocketServer.java @@ -78,6 +78,9 @@ protected static void doMain(Class serverClass, LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory(); configureLC(lc, configFile); + // Unlike upstream logback 1.2, which ignores serverClass and always + // starts a plain SimpleSocketServer (so SimpleSSLSocketServer.main + // started a server without SSL), create the requested class. SimpleSocketServer sss = serverClass.getConstructor(LoggerContext.class, int.class) .newInstance(lc, port); sss.start(); From 9497c8b3bc608151c64ddb49db3898fd9c823393 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:09:45 +0000 Subject: [PATCH 78/79] revert: keep OutputStreamAppender.subAppend() off writeOut() Routing subAppend() through writeOut() (7226724) made prudent mode lock the file, but FileAppender.safeWrite() takes the FileChannel lock without holding the appender's lock, so when two threads log at once the second lock() throws OverlappingFileLockException and the event is dropped. Upstream logback hit the same regression after 1.3.0 and fixed it by taking the stream lock around the file lock; that change to prudent mode belongs in its own pull request. Restore the 1.2 behavior and cover writeOut() and safeWrite() by calling them directly. OutputStreamAppenderTest drops eventsAreWrittenThroughWriteOut and calls writeOut() directly instead: the encoded event is written and flushed, an empty or null encoding writes nothing, and a write failure is thrown to the caller. FileAppenderTest's prudent-mode tests call FileAppender.writeOut() through FileAppenderFriend, and prudentAppenderKeepsEveryEventLoggedConcurrently has two threads log to a prudent appender with their first events overlapping, which drops one thread's events with the 7226724 behavior. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../logback/core/OutputStreamAppender.java | 6 +- .../core/OutputStreamAppenderTest.java | 57 +++++-- .../core/appender/FileAppenderTest.java | 142 +++++++++++++++--- 3 files changed, 162 insertions(+), 43 deletions(-) diff --git a/logback-android/src/main/java/ch/qos/logback/core/OutputStreamAppender.java b/logback-android/src/main/java/ch/qos/logback/core/OutputStreamAppender.java index ea6b81cc7..cc1191e93 100644 --- a/logback-android/src/main/java/ch/qos/logback/core/OutputStreamAppender.java +++ b/logback-android/src/main/java/ch/qos/logback/core/OutputStreamAppender.java @@ -241,10 +241,8 @@ protected void subAppend(E event) { // are writing. It also prevents multiple threads from entering the same // converter. Converters assume that they are in a synchronized block. //lock.lock(); - // Go through writeOut() so that subclasses can wrap the write, e.g. - // FileAppender in prudent mode, which locks the file around it - // (as upstream logback does since 1.3.0). - writeOut(event); + byte[] byteArray = this.encoder.encode(event); + writeBytes(byteArray); } catch (IOException ioe) { // as soon as an exception occurs, move to non-started state diff --git a/logback-android/src/test/java/ch/qos/logback/core/OutputStreamAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/core/OutputStreamAppenderTest.java index a378fb1f8..7344343f0 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/OutputStreamAppenderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/OutputStreamAppenderTest.java @@ -21,6 +21,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -137,26 +138,54 @@ String emtptyIfNull(String s) { } @Test - public void eventsAreWrittenThroughWriteOut() { - final List writtenOut = new ArrayList(); - OutputStreamAppender appender = new OutputStreamAppender() { + public void writeOutWritesEncodedEventAndFlushes() throws IOException { + OutputStreamAppender appender = newAppender(new Utf8LineEncoder()); + FaultInjectingOutputStream out = new FaultInjectingOutputStream(); + appender.setOutputStream(out); + appender.start(); + + appender.writeOut("a"); + appender.writeOut("b"); + + assertEquals("a\nb\n", out.text()); + assertEquals(2, out.flushCount); + statusChecker.assertIsErrorFree(); + } + + @Test + public void writeOutWritesNothingForEmptyOrNullEncoding() throws IOException { + Utf8LineEncoder encoder = new Utf8LineEncoder() { @Override - protected void writeOut(Object event) throws IOException { - writtenOut.add(event); - super.writeOut(event); + public byte[] encode(Object event) { + return event == null ? null : new byte[0]; } }; - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - appender.setContext(context); - appender.setEncoder(new Utf8LineEncoder()); - appender.setOutputStream(baos); + OutputStreamAppender appender = newAppender(encoder); + FaultInjectingOutputStream out = new FaultInjectingOutputStream(); + appender.setOutputStream(out); appender.start(); - appender.doAppend("a"); - appender.doAppend("b"); + appender.writeOut("empty"); + appender.writeOut(null); + + assertEquals("", out.text()); + assertEquals(0, out.flushCount); + } + + @Test + public void writeOutPropagatesWriteFailure() { + OutputStreamAppender appender = newAppender(new Utf8LineEncoder()); + FaultInjectingOutputStream out = new FaultInjectingOutputStream(); + appender.setOutputStream(out); + appender.start(); + out.failWrites = true; + + IOException e = assertThrows(IOException.class, () -> appender.writeOut("lost")); - assertEquals(Arrays.asList("a", "b"), writtenOut); - assertEquals("a\nb\n", utf8(baos)); + assertEquals("write failed", e.getMessage()); + // it is up to the caller to handle the failure + assertTrue(appender.isStarted()); + statusChecker.assertIsErrorFree(); } @Test diff --git a/logback-android/src/test/java/ch/qos/logback/core/appender/FileAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/core/appender/FileAppenderTest.java index af19c42cf..32dfab4a7 100644 --- a/logback-android/src/test/java/ch/qos/logback/core/appender/FileAppenderTest.java +++ b/logback-android/src/test/java/ch/qos/logback/core/appender/FileAppenderTest.java @@ -35,6 +35,8 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; import ch.qos.logback.core.ContextBase; @@ -244,13 +246,13 @@ public void fileNameCollision() { public void prudentModeHoldsFileLockWhileEventIsWritten() throws Exception { File file = new File(tmp.getRoot(), "prudent-lock.log"); LockProbingEncoder encoder = new LockProbingEncoder(file); - FileAppender fa = newFileAppender("prudent-lock", file, encoder); + FileAppenderFriend fa = newFileAppender("prudent-lock", file, encoder); fa.setPrudent(true); fa.start(); // start from a non-interrupted thread Thread.interrupted(); - fa.doAppend("hello"); + fa.writeOut("hello"); // reads and clears the flag, so that it cannot leak into later tests boolean interruptedAfterAppend = Thread.interrupted(); fa.stop(); @@ -266,17 +268,21 @@ public void prudentModeHoldsFileLockWhileEventIsWritten() throws Exception { public void lazyPrudentAppenderLocksFileItOpensOnFirstAppend() throws Exception { File file = new File(tmp.getRoot(), "lazy-prudent.log"); LockProbingEncoder encoder = new LockProbingEncoder(file); - FileAppender fa = newFileAppender("lazy-prudent", file, encoder); + FileAppenderFriend fa = newFileAppender("lazy-prudent", file, encoder); fa.setPrudent(true); fa.setLazy(true); fa.start(); assertFalse(file.exists()); + // the first event opens the file fa.doAppend("a"); - fa.doAppend("b"); + assertTrue(fa.getOutputStream() instanceof ResilientFileOutputStream); + fa.writeOut("b"); fa.stop(); - assertEquals(Arrays.asList(Boolean.TRUE, Boolean.TRUE), encoder.lockHeldDuringEncode); + // the prudent write locked the file that was opened lazily + assertEquals(2, encoder.lockHeldDuringEncode.size()); + assertEquals(Boolean.TRUE, encoder.lockHeldDuringEncode.get(1)); assertEquals("a\nb\n", readUtf8(file)); new StatusChecker(context).assertIsErrorFree(); } @@ -285,10 +291,10 @@ public void lazyPrudentAppenderLocksFileItOpensOnFirstAppend() throws Exception public void nonPrudentModeDoesNotLockFile() throws Exception { File file = new File(tmp.getRoot(), "non-prudent.log"); LockProbingEncoder encoder = new LockProbingEncoder(file); - FileAppender fa = newFileAppender("non-prudent", file, encoder); + FileAppenderFriend fa = newFileAppender("non-prudent", file, encoder); fa.start(); - fa.doAppend("hello"); + fa.writeOut("hello"); fa.stop(); assertEquals(Collections.singletonList(Boolean.FALSE), encoder.lockHeldDuringEncode); @@ -437,7 +443,7 @@ public void eventsLargerThanBufferSizeReachFileWithoutFlush() throws IOException @Test public void prudentWriteSeeksPastDataAppendedByAnotherWriter() throws IOException { File file = new File(tmp.getRoot(), "shared.log"); - FileAppender fa = newFileAppender("shared", file, new Utf8LineEncoder()); + FileAppenderFriend fa = newFileAppender("shared", file, new Utf8LineEncoder()); // not in append mode, so the stream's position does not follow the end of file fa.setAppend(false); fa.start(); @@ -445,7 +451,7 @@ public void prudentWriteSeeksPastDataAppendedByAnotherWriter() throws IOExceptio appendUtf8(file, "other\n"); fa.setPrudent(true); - fa.doAppend("b"); + fa.writeOut("b"); fa.stop(); assertEquals("a\nother\nb\n", readUtf8(file)); @@ -455,14 +461,14 @@ public void prudentWriteSeeksPastDataAppendedByAnotherWriter() throws IOExceptio @Test public void prudentWriteRestoresInterruptFlag() throws IOException { File file = new File(tmp.getRoot(), "interrupted.log"); - FileAppender fa = newFileAppender("interrupted", file, new Utf8LineEncoder()); + FileAppenderFriend fa = newFileAppender("interrupted", file, new Utf8LineEncoder()); fa.setPrudent(true); fa.start(); boolean interruptedAfterAppend; Thread.currentThread().interrupt(); try { - fa.doAppend("hello"); + fa.writeOut("hello"); } finally { // also clears the flag for later tests interruptedAfterAppend = Thread.interrupted(); @@ -478,19 +484,19 @@ public void prudentWriteRestoresInterruptFlag() throws IOException { @Test public void prudentWriteToClosedChannelIsReportedAsIOFailure() throws IOException { File file = new File(tmp.getRoot(), "closed.log"); - FileAppender fa = newFileAppender("closed", file, new Utf8LineEncoder()); + FileAppenderFriend fa = newFileAppender("closed", file, new Utf8LineEncoder()); fa.setPrudent(true); fa.start(); ((ResilientFileOutputStream) fa.getOutputStream()).getChannel().close(); - fa.doAppend("lost"); + // returns normally: the failure is left to the resilient stream to + // recover from instead of being thrown at the appender, which would stop it + fa.writeOut("lost"); - // the failure is left to the resilient stream to recover from assertTrue(fa.isStarted()); StatusChecker checker = new StatusChecker(context); checker.assertContainsMatch(Status.ERROR, "IO failure while writing to file"); checker.asssertContainsException(ClosedChannelException.class); - checker.assertNoMatch("failed to append"); assertEquals("", readUtf8(file)); fa.stop(); } @@ -499,36 +505,89 @@ public void prudentWriteToClosedChannelIsReportedAsIOFailure() throws IOExceptio public void prudentWriteDoesNotReleaseLockInvalidatedDuringWrite() throws IOException { File file = new File(tmp.getRoot(), "invalidated.log"); ChannelClosingEncoder encoder = new ChannelClosingEncoder(); - FileAppender fa = newFileAppender("invalidated", file, encoder); + FileAppenderFriend fa = newFileAppender("invalidated", file, encoder); encoder.appender = fa; fa.setPrudent(true); fa.start(); - fa.doAppend("lost"); + // returns normally: releasing the invalidated lock would have thrown + // ClosedChannelException out of writeOut(), which would stop the appender + fa.writeOut("lost"); - // releasing the invalidated lock would have thrown and stopped the appender assertTrue(fa.isStarted()); - StatusChecker checker = new StatusChecker(context); - checker.assertNoMatch("IO failure in appender"); - checker.assertContainsMatch(Status.ERROR, "IO failure while writing to file"); + new StatusChecker(context).assertContainsMatch(Status.ERROR, "IO failure while writing to file"); fa.stop(); } @Test public void prudentWriteIsSkippedWhenStreamHasNoChannel() throws IOException { File file = new File(tmp.getRoot(), "no-channel.log"); - FileAppender fa = newFileAppender("no-channel", file, new Utf8LineEncoder()); + FileAppenderFriend fa = newFileAppender("no-channel", file, new Utf8LineEncoder()); fa.setPrudent(true); fa.start(); fa.setOutputStream(new ChannelLessResilientFileOutputStream(file)); - fa.doAppend("dropped"); + fa.writeOut("dropped"); fa.stop(); assertEquals("", readUtf8(file)); new StatusChecker(context).assertIsErrorFree(); } + @Test + public void prudentAppenderKeepsEveryEventLoggedConcurrently() throws Exception { + File file = new File(tmp.getRoot(), "prudent-concurrent.log"); + final int threadCount = 2; + final int eventsPerThread = 200; + // the threads' first events are encoded at the same time, i.e. each while + // the other thread's append is in progress. Routing appends through the + // prudent writeOut(), which locks the file without holding the appender's + // lock, made the second thread's FileChannel.lock() throw + // OverlappingFileLockException there, and its events were dropped. + final FileAppender fa = newFileAppender("prudent-concurrent", file, + new RendezvousEncoder(threadCount)); + fa.setPrudent(true); + fa.start(); + + final CountDownLatch startGate = new CountDownLatch(1); + final List failures = Collections.synchronizedList(new ArrayList()); + List threads = new ArrayList(); + List expected = new ArrayList(); + for (int t = 0; t < threadCount; t++) { + final String prefix = "thread" + t + "-event"; + for (int i = 0; i < eventsPerThread; i++) { + expected.add(prefix + i); + } + Thread thread = new Thread(() -> { + try { + startGate.await(); + for (int i = 0; i < eventsPerThread; i++) { + fa.doAppend(prefix + i); + } + } catch (Throwable e) { + failures.add(e); + } + }, "prudent-writer-" + t); + thread.start(); + threads.add(thread); + } + + startGate.countDown(); + for (Thread thread : threads) { + thread.join(TimeUnit.SECONDS.toMillis(30)); + assertFalse(thread.getName() + " did not finish", thread.isAlive()); + } + fa.stop(); + + assertEquals(Collections.emptyList(), failures); + List lines = new ArrayList(Arrays.asList(readUtf8(file).split("\n"))); + assertEquals("events in the file", expected.size(), lines.size()); + Collections.sort(expected); + Collections.sort(lines); + assertEquals(expected, lines); + new StatusChecker(context).assertIsErrorFree(); + } + @Test public void lazyAppenderOpensFileOnlyOnce() throws IOException { File file = new File(tmp.getRoot(), "lazy-once.log"); @@ -587,8 +646,8 @@ public void lazyAppenderStopsWhenFileCannotBeOpened() { checker.asssertContainsException(FileNotFoundException.class); } - private FileAppender newFileAppender(String name, File file, EncoderBase encoder) { - FileAppender fa = new FileAppender(); + private FileAppenderFriend newFileAppender(String name, File file, EncoderBase encoder) { + FileAppenderFriend fa = new FileAppenderFriend(); fa.setContext(context); fa.setName(name); fa.setEncoder(encoder); @@ -654,6 +713,32 @@ public byte[] encode(Object event) { } } + /** + * Holds each event in encode() until as many events as there are parties + * are being encoded at the same time, so that the first events of that many + * threads are written concurrently; later events are not held. The wait is + * bounded, and the events are encoded when it ends either way, so the + * outcome does not depend on how long it takes. + */ + static class RendezvousEncoder extends Utf8LineEncoder { + final CountDownLatch encoding; + + RendezvousEncoder(int parties) { + encoding = new CountDownLatch(parties); + } + + @Override + public byte[] encode(Object event) { + encoding.countDown(); + try { + encoding.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return super.encode(event); + } + } + /** * A ResilientFileOutputStream whose underlying stream is gone, so it has no * file channel. @@ -709,5 +794,12 @@ class FileAppenderFriend extends FileAppender { public void append(E obj) { this.subAppend(obj); } + + // doAppend() does not go through writeOut() (subAppend() encodes and + // writes the bytes itself), so tests of prudent mode call it directly + @Override + public void writeOut(E event) throws IOException { + super.writeOut(event); + } } } From 89c09b27914240637513b9eda9386ebb7402e1d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:17:55 +0000 Subject: [PATCH 79/79] test: cover SocketReceiver's interrupted wait deterministically run()'s catch (InterruptedException) was covered only when a test's stop() happened to interrupt the receiver while it waited for its connector, so on one CI run (jdk8 variant) the coverage gate missed that line. Interrupt the receiver's thread before it waits for a connector that cannot finish, so the wait is interrupted on every run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157UjTSjJyouKBfY7Zwxeig --- .../classic/net/SocketReceiverTest.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/logback-android/src/test/java/ch/qos/logback/classic/net/SocketReceiverTest.java b/logback-android/src/test/java/ch/qos/logback/classic/net/SocketReceiverTest.java index fab43d8ce..d3b24ed83 100644 --- a/logback-android/src/test/java/ch/qos/logback/classic/net/SocketReceiverTest.java +++ b/logback-android/src/test/java/ch/qos/logback/classic/net/SocketReceiverTest.java @@ -39,6 +39,7 @@ import java.util.Arrays; import java.util.Deque; import java.util.List; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; @@ -249,6 +250,44 @@ public void runEndsWhenTheConnectorFails() throws Exception { new StatusChecker(lc).assertNoMatch("connection established"); } + @Test + public void runEndsWhenInterruptedWhileWaitingForTheConnector() throws Exception { + final CountDownLatch release = new CountDownLatch(1); + ScriptedSocketReceiver scripted = new ScriptedSocketReceiver() { + @Override + protected SocketConnector newConnector(InetAddress address, int port, + int initialDelay, int retryDelay) { + // run() is on this (the test) thread: interrupt it before it waits + // for a connector that can't finish, so that the wait is always + // interrupted rather than only when stop() happens to land there + Thread.currentThread().interrupt(); + return new ScriptedConnector(null) { + @Override + public Socket call() { + try { + release.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return null; + } + }; + } + }; + configure(scripted); + + try { + scripted.run(); + } finally { + release.countDown(); + } + + // the wait consumed the interrupt + assertFalse(Thread.interrupted()); + assertEquals("shutting down", lastStatus().getMessage()); + new StatusChecker(lc).assertNoMatch("connection established"); + } + @Test public void dispatchReportsEndOfStreamThenReconnects() throws Exception { logger.setLevel(Level.DEBUG);