Skip to content

Commit bdb87e8

Browse files
test: Improve core module coverage toward 90% target (#107) (#107)
Add unit tests for uncovered paths in PatchEngine, FilterToPredicateVisitor, and SchemaRegistry: - PatchEngine: filtered multi-valued add/error, single-item array append, no-path replace, no-path mutability enforcement - FilterToPredicateVisitor: null comparisons (ne null, gt null), numeric edge cases (ge/le/lt), non-list value path, string operator with non-string expected - SchemaRegistry: extension error paths (missing annotation, unregistered resource type) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2878c1d commit bdb87e8

3 files changed

Lines changed: 183 additions & 0 deletions

File tree

scim2-sdk-core/src/test/kotlin/com/marcosbarbero/scim2/core/filter/visitor/FilterToPredicateVisitorTest.kt

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,5 +209,73 @@ class FilterToPredicateVisitorTest {
209209
matches("emails[type eq \"work\"].value", sampleUser) shouldBe true
210210
matches("emails[type eq \"${faker.name.name()}\"].value", sampleUser) shouldBe false
211211
}
212+
213+
@Test
214+
fun `should return false when value path attribute is not a list`() {
215+
matches("userName[type eq \"work\"]", sampleUser) shouldBe false
216+
}
217+
218+
@Test
219+
fun `should return false when value path has no matching items`() {
220+
matches("emails[type eq \"nonexistent\"]", sampleUser) shouldBe false
221+
}
222+
}
223+
224+
@Nested
225+
inner class NullComparisons {
226+
227+
@Test
228+
fun `ne null should match present attribute`() {
229+
matches("userName ne null", sampleUser) shouldBe true
230+
}
231+
232+
@Test
233+
fun `ne null should not match absent attribute`() {
234+
matches("nonExistent ne null", sampleUser) shouldBe false
235+
}
236+
237+
@Test
238+
fun `gt null should return false`() {
239+
matches("age gt null", sampleUser) shouldBe false
240+
}
241+
}
242+
243+
@Nested
244+
inner class NumericEdgeCases {
245+
246+
@Test
247+
fun `gt with non-numeric expected should return false`() {
248+
matches("age gt \"notanumber\"", sampleUser) shouldBe false
249+
}
250+
251+
@Test
252+
fun `ge should match equal numeric value`() {
253+
matches("age ge 30", sampleUser) shouldBe true
254+
}
255+
256+
@Test
257+
fun `le should match equal numeric value`() {
258+
matches("age le 30", sampleUser) shouldBe true
259+
}
260+
261+
@Test
262+
fun `lt should not match equal value`() {
263+
matches("age lt 30", sampleUser) shouldBe false
264+
}
265+
266+
@Test
267+
fun `numeric comparison with absent attribute returns false`() {
268+
matches("nonExistent gt 0", sampleUser) shouldBe false
269+
}
270+
}
271+
272+
@Nested
273+
inner class StringOperatorEdgeCases {
274+
275+
@Test
276+
fun `co with non-string expected should return false`() {
277+
// co operator on numeric expected value
278+
matches("userName co 123", sampleUser) shouldBe false
279+
}
212280
}
213281
}

scim2-sdk-core/src/test/kotlin/com/marcosbarbero/scim2/core/patch/PatchEngineTest.kt

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import com.fasterxml.jackson.annotation.JsonInclude
1919
import com.marcosbarbero.scim2.core.domain.model.common.MultiValuedAttribute
2020
import com.marcosbarbero.scim2.core.domain.model.error.InvalidPathException
2121
import com.marcosbarbero.scim2.core.domain.model.error.InvalidValueException
22+
import com.marcosbarbero.scim2.core.domain.model.error.MutabilityException
2223
import com.marcosbarbero.scim2.core.domain.model.patch.PatchOp
2324
import com.marcosbarbero.scim2.core.domain.model.patch.PatchOperation
2425
import com.marcosbarbero.scim2.core.domain.model.patch.PatchRequest
@@ -291,6 +292,96 @@ class PatchEngineTest {
291292
}
292293
}
293294

295+
@Nested
296+
inner class FilteredMultiValuedOperations {
297+
298+
@Test
299+
fun `should add to filtered multi-valued attribute`() {
300+
val user = baseUser()
301+
val request = PatchRequest(
302+
operations = listOf(
303+
PatchOperation(
304+
op = PatchOp.ADD,
305+
path = """emails[type eq "work"]""",
306+
value = objectMapper.valueToTree(mapOf("display" to "Work Email")),
307+
),
308+
),
309+
)
310+
311+
val result = engine.apply(user, request)
312+
val workEmail = result.emails.first { it.type == "work" }
313+
workEmail.display shouldBe "Work Email"
314+
}
315+
316+
@Test
317+
fun `should throw when adding to filtered multi-valued with non-object value`() {
318+
val user = baseUser()
319+
val request = PatchRequest(
320+
operations = listOf(
321+
PatchOperation(
322+
op = PatchOp.ADD,
323+
path = """emails[type eq "work"]""",
324+
value = objectMapper.valueToTree("not an object"),
325+
),
326+
),
327+
)
328+
329+
shouldThrow<InvalidValueException> {
330+
engine.apply(user, request)
331+
}
332+
}
333+
334+
@Test
335+
fun `should add single item to existing array`() {
336+
val user = baseUser()
337+
val newEmail = mapOf("value" to "single@example.com", "type" to "other")
338+
val request = PatchRequest(
339+
operations = listOf(
340+
PatchOperation(op = PatchOp.ADD, path = "emails", value = objectMapper.valueToTree(newEmail)),
341+
),
342+
)
343+
344+
val result = engine.apply(user, request)
345+
result.emails shouldHaveSize 3
346+
}
347+
}
348+
349+
@Nested
350+
inner class ReplaceWithNoPath {
351+
352+
@Test
353+
fun `should replace with no path by merging object value`() {
354+
val user = baseUser()
355+
val newDisplayName = faker.name.name()
356+
val request = PatchRequest(
357+
operations = listOf(
358+
PatchOperation(op = PatchOp.REPLACE, value = objectMapper.valueToTree(mapOf("displayName" to newDisplayName))),
359+
),
360+
)
361+
362+
val result = engine.apply(user, request)
363+
result.displayName shouldBe newDisplayName
364+
}
365+
}
366+
367+
@Nested
368+
inner class MutabilityWithNoPath {
369+
370+
@Test
371+
fun `should reject no-path replace containing readOnly attribute`() {
372+
val user = baseUser()
373+
val request = PatchRequest(
374+
operations = listOf(
375+
PatchOperation(op = PatchOp.REPLACE, value = objectMapper.valueToTree(mapOf("id" to "hacked"))),
376+
),
377+
)
378+
379+
shouldThrow<MutabilityException> {
380+
engine.apply(user, request)
381+
}
382+
}
383+
}
384+
294385
@Nested
295386
inner class PreservesIdentity {
296387

scim2-sdk-core/src/test/kotlin/com/marcosbarbero/scim2/core/schema/introspector/SchemaRegistryTest.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ import com.marcosbarbero.scim2.core.domain.model.resource.EnterpriseUserExtensio
2020
import com.marcosbarbero.scim2.core.domain.model.resource.Group
2121
import com.marcosbarbero.scim2.core.domain.model.resource.User
2222
import io.github.serpro69.kfaker.Faker
23+
import io.kotest.assertions.throwables.shouldThrow
2324
import io.kotest.matchers.collections.shouldHaveSize
2425
import io.kotest.matchers.nulls.shouldBeNull
2526
import io.kotest.matchers.nulls.shouldNotBeNull
2627
import io.kotest.matchers.shouldBe
28+
import io.kotest.matchers.string.shouldContain
2729
import org.junit.jupiter.api.BeforeEach
2830
import org.junit.jupiter.api.Nested
2931
import org.junit.jupiter.api.Test
@@ -127,6 +129,28 @@ class SchemaRegistryTest {
127129
}
128130
}
129131

132+
@Nested
133+
inner class ExtensionErrorPaths {
134+
135+
@Test
136+
fun `should throw when extension class has no ScimExtension annotation`() {
137+
registry.register(User::class)
138+
val ex = shouldThrow<IllegalArgumentException> {
139+
registry.registerExtension(User::class, User::class)
140+
}
141+
ex.message shouldContain "@ScimExtension"
142+
}
143+
144+
@Test
145+
fun `should throw when resource type not registered before adding extension`() {
146+
// Don't register User first
147+
val ex = shouldThrow<IllegalStateException> {
148+
registry.registerExtension(User::class, EnterpriseUserExtension::class)
149+
}
150+
ex.message shouldContain "must be registered before"
151+
}
152+
}
153+
130154
@Nested
131155
inner class ThreadSafetyTest {
132156
@Test

0 commit comments

Comments
 (0)