1 package expo.modules.test.core
2 
3 import com.facebook.react.bridge.ReadableArray
4 import expo.modules.kotlin.ModuleHolder
5 import expo.modules.kotlin.Promise
6 import expo.modules.kotlin.types.JSTypeConverter
7 import java.lang.reflect.InvocationHandler
8 import java.lang.reflect.Method
9 import kotlin.reflect.KClass
10 
11 /**
12  * The promise rejection will be converted into this exception.
13  */
14 class TestCodedException(
15   code: String,
16   message: String?,
17   cause: Throwable?
18 ) : Exception("[$code] $message", cause)
19 
20 /**
21  * Mocked module invocation handler which dispatches a call on test interface to the corresponding
22  * exported function or to the module controller if the method doesn't exist in the module definition.
23  *
24  * Methods mapping:
25  *   AsyncFunction("name") { args: ArgsType -> return ReturnType } can be invoked using one of the following methods mapping rules:
26  *     - [non-promise mapping] fun ModuleTestInterface.name(args: ArgsType): ReturnType
27  *     - [promise mapping] fun ModuleTestInterface.name(args: ArgsType, promise: Promise): Unit
28  *
29  *   AsyncFunction("name") { args: ArgsType, promise: Promise -> promise.resolve(ReturnType) } can be invoked using one of the following methods mapping rules:
30  *     - [non-promise mapping] fun ModuleTestInterface.name(args: ArgsType): ReturnType
31  *     - [promise mapping] fun ModuleTestInterface.name(args: ArgsType, promise: Promise): Unit
32  *
33  *   Function("name") { args: ArgsType -> return ReturnType } can be invoked using non-promise mapping only:
34  *     - fun ModuleTestInterface.name(args: ArgsType): ReturnType
35  *
36  * In tests, the non-promise mapping should be preferred if possible.
37  * The promise mapping should be only used when dealing with native async code.
38  *
39  * In the non-promise mapping, rejection will be converted into exceptions.
40  * If you want to test if the method rejects, add the `@Throws` annotation to the `ModuleTestInterface` method you are testing.
41  * Otherwise, the exception will be wrapped in `UndeclaredThrowableException`.
42  */
43 class ModuleMockInvocationHandler<T : Any>(
44   private val moduleTestInterface: KClass<T>,
45   private val moduleController: ModuleController,
46   private val holder: ModuleHolder
47 ) : InvocationHandler {
invokenull48   override fun invoke(proxy: Any, method: Method, args: Array<out Any>?): Any? {
49     if (!holder.definition.asyncFunctions.containsKey(method.name) &&
50       !holder.definition.syncFunctions.containsKey(method.name)
51     ) {
52       return method.invoke(moduleController, *(args ?: emptyArray()))
53     }
54 
55     return callExportedFunction(method.name, args)
56   }
57 
callExportedFunctionnull58   private fun callExportedFunction(methodName: String, args: Array<out Any>?): Any? {
59     if (holder.definition.syncFunctions.containsKey(methodName)) {
60       // Call as a sync function
61       return holder.callSync(methodName, convertArgs(args?.asList() ?: emptyList()))
62     }
63 
64     if (holder.definition.asyncFunctions.containsKey(methodName)) {
65       // We know it's a async function, but we don't know which mapping we're using
66       val lastArg = args?.lastOrNull()
67       if (Promise::class.java.isInstance(lastArg)) {
68         promiseMappingCall(methodName, args!!.dropLast(1), lastArg as Promise)
69         return Unit
70       }
71 
72       return nonPromiseMappingCall(methodName, args)
73     }
74 
75     throw IllegalStateException("Module class method '$methodName' not found")
76   }
77 
nonPromiseMappingCallnull78   private fun nonPromiseMappingCall(methodName: String, args: Array<out Any>?): Any? {
79     val mockedPromise = PromiseMock()
80     holder.call(methodName, convertArgs(args?.asList() ?: emptyList()), mockedPromise)
81 
82     when (mockedPromise.state) {
83       PromiseState.RESOLVED -> {
84         val moduleClassMethod = moduleTestInterface.members.firstOrNull { it.name == methodName }
85           ?: throw IllegalStateException("Module class method '$methodName' not found")
86 
87         if (mockedPromise.resolveValue == null) {
88           if (moduleClassMethod.returnType.isMarkedNullable) {
89             return null
90           }
91 
92           throw IllegalStateException("Method returns 'null' but the non-nullable type was expected")
93         }
94 
95         if (!(moduleClassMethod.returnType.classifier as KClass<*>).isInstance(mockedPromise.resolveValue)) {
96           throw IllegalStateException("Illegal return type ${mockedPromise.resolveValue?.javaClass}, expected ${moduleClassMethod.returnType.classifier}.")
97         }
98 
99         return mockedPromise.resolveValue
100       }
101       PromiseState.REJECTED ->
102         throw TestCodedException(
103           mockedPromise.rejectCode!!,
104           mockedPromise.rejectMessage,
105           mockedPromise.rejectThrowable
106         )
107       PromiseState.NONE, PromiseState.ILLEGAL ->
108         throw IllegalStateException("Illegal promise state '${mockedPromise.state}'")
109     }
110   }
111 
promiseMappingCallnull112   private fun promiseMappingCall(methodName: String, args: List<Any>, promise: Promise) {
113     holder.call(methodName, convertArgs(args), promise)
114   }
115 
syncCallnull116   private fun syncCall(methodName: String, args: Iterable<Any?>): Any? {
117     return holder.callSync(methodName, convertArgs(args))
118   }
119 
convertArgsnull120   private fun convertArgs(args: Iterable<Any?>): ReadableArray {
121     return JSTypeConverter.convertToJSValue(args, TestJSContainerProvider) as ReadableArray
122   }
123 }
124