Commit c99aaf01 authored by xie.qin's avatar xie.qin

to support grpc interface. temp commit.

parent 31146c0b
English, please click [here](./HELP.md) English, please click [here](./HELP.md)
# 一款实用的API和SDK自动化测试框架 # 一款实用的API和SDK自动化测试框架
[toc]
## 1. API自动化测试 ## 1. API自动化测试
```
微服务时代,API的主要实现方式有 REST 和 RPC 两种。不同的接口形式,理所当然需要对应不同的测试方法。 微服务时代,API的主要实现方式有 REST 和 RPC 两种。不同的接口形式,理所当然需要对应不同的测试方法。
```
### 1.1 REST API的自动化测试 ### 1.1 REST API的自动化测试
#### 1.1.1 验证所有发布的 API 是否都能工作(冒烟测试) #### 1.1.1 验证所有发布的 API 是否都能工作(冒烟测试)
##### 测试方法 ##### 测试方法
```
1. 根据微服务提供的 OPEN API 文件,得到其对外提供的所有 REST API 列表(包括路径、方法、请求消息体结构和接收消息体结构等) 1. 根据微服务提供的 OPEN API 文件,得到其对外提供的所有 REST API 列表(包括路径、方法、请求消息体结构和接收消息体结构等)
2. 构造请求消息体时: 2. 构造请求消息体时:
- 对于简单数据类型(如:integer, number, string, boolean),赋值为此数据类型在此 OPEN API 文件中定义的最大边界值。 - 对于简单数据类型(如:integer, number, string, boolean),赋值为此数据类型在此 OPEN API 文件中定义的最大边界值。
...@@ -20,22 +21,26 @@ English, please click [here](./HELP.md) ...@@ -20,22 +21,26 @@ English, please click [here](./HELP.md)
- 简单数据类型仅验证返回的数据类型是否与 OPEN API 文件中预定义的类型匹配,不要求值匹配; - 简单数据类型仅验证返回的数据类型是否与 OPEN API 文件中预定义的类型匹配,不要求值匹配;
- 复合数据类型 object,需要验证响应消息中是否包含所有的期望 key ,以及匹配 value 的数据类型是否匹配期望; - 复合数据类型 object,需要验证响应消息中是否包含所有的期望 key ,以及匹配 value 的数据类型是否匹配期望;
- 复合数据类型 array,需要验证响应消息中是否包含复合类型 object ?是,则需要验证 key 和数据类型;否则仅需要验证数据类型是否匹配期望。 - 复合数据类型 array,需要验证响应消息中是否包含复合类型 object ?是,则需要验证 key 和数据类型;否则仅需要验证数据类型是否匹配期望。
```
##### 用例参考 ##### 用例参考
```
- [点击此链接](/src/test/resources/features/sanitytest/backend.feature) - [点击此链接](/src/test/resources/features/sanitytest/backend.feature)
- ![结果截图](/gallery/sanity_test.png) - ![结果截图](/gallery/sanity_test.png)
```
##### 优点 ##### 优点
```
- 无需测试人员手动编写测试脚本,测试框架全自动完成 OpenAPI 分析及 REST 消息的发送和结果验证。 - 无需测试人员手动编写测试脚本,测试框架全自动完成 OpenAPI 分析及 REST 消息的发送和结果验证。
- 根据不同微服务的自我定义,自动适配其支持的协议类型(如,http, https, http2等)进行消息的发送和接收。 - 根据不同微服务的自我定义,自动适配其支持的协议类型(如,http, https, http2等)进行消息的发送和接收。
- 执行速度快,436个 API,请求和验证可以在40秒内完成。 - 执行速度快,436个 API,请求和验证可以在40秒内完成。
```
##### 局限性 ##### 局限性
```
- 未考虑实际的业务需求;仅限于最基本的可行性验证。 - 未考虑实际的业务需求;仅限于最基本的可行性验证。
- 返回失败的请求需要人工二次校验其准确性(如果事先把测试数据准备好,则可规避此问题,但需要付出更多的人力)。 - 返回失败的请求需要人工二次校验其准确性(如果事先把测试数据准备好,则可规避此问题,但需要付出更多的人力)。
```
#### 1.1.2 编写符合实际业务需求的 API 测试用例(功能测试) #### 1.1.2 编写符合实际业务需求的 API 测试用例(功能测试)
##### 测试方法 ##### 测试方法
```
1. 准备json格式的测试数据,包括: 1. 准备json格式的测试数据,包括:
- 发送请求实时数据 - 发送请求实时数据
- 接收响应实时数据 [格式参考](./src/test/resources/testdatacollection/v2.2.0/runtime/user_management/UserController.json) - 接收响应实时数据 [格式参考](./src/test/resources/testdatacollection/v2.2.0/runtime/user_management/UserController.json)
...@@ -47,20 +52,24 @@ English, please click [here](./HELP.md) ...@@ -47,20 +52,24 @@ English, please click [here](./HELP.md)
3. 测试框架根据测试人员提供的接收响应数据,进行消息结果验证: 3. 测试框架根据测试人员提供的接收响应数据,进行消息结果验证:
- 符合预期,则测试通过; - 符合预期,则测试通过;
- 否则打印错误断言,测试退出并标记为失败。 - 否则打印错误断言,测试退出并标记为失败。
```
##### 用例参考 ##### 用例参考
```
- [点击此链接](/src/test/resources/features/apitest/restful/user_management/user_register.feature) - [点击此链接](/src/test/resources/features/apitest/restful/user_management/user_register.feature)
```
##### 优点 ##### 优点
```
- 用例编写简单,核心测试脚本仅数行。 - 用例编写简单,核心测试脚本仅数行。
- 消息的发送、接收以及结果验证对用例编写人员透明。测试用例编写人员需要关心的只是产品业务需求。 - 消息的发送、接收以及结果验证对用例编写人员透明。测试用例编写人员需要关心的只是产品业务需求。
- 测试数据与功能需求关联,支持复用。不同测试脚本(用例)可使用相同测试数据。 - 测试数据与功能需求关联,支持复用。不同测试脚本(用例)可使用相同测试数据。
```
##### 局限性 ##### 局限性
```
- 更多需要讨论的是 API 测试本身的局限性,和本测试框架无关。 - 更多需要讨论的是 API 测试本身的局限性,和本测试框架无关。
```
#### 1.1.3 构造微服务 mock server,根据不同的请求返回不同的响应指定结果 (集成测试) #### 1.1.3 构造微服务 mock server,根据不同的请求返回不同的响应指定结果 (集成测试)
##### 测试方法 ##### 测试方法
```
1. 准备json格式的请求响应数据; 1. 准备json格式的请求响应数据;
2. 不同于 1.1.1 和 1.1.2 部分,mock server将无法集成到一个具体的测试用例中,它应该被部署为一个真实服务供客户端服务(在开发中的测试服务对象)调用; 2. 不同于 1.1.1 和 1.1.2 部分,mock server将无法集成到一个具体的测试用例中,它应该被部署为一个真实服务供客户端服务(在开发中的测试服务对象)调用;
3. 启动命令:java -jar auto-test-1.0.0-SNAPSHOT.jar --mvcmock.response.path=<> --server.ssl.enabled=<> 3. 启动命令:java -jar auto-test-1.0.0-SNAPSHOT.jar --mvcmock.response.path=<> --server.ssl.enabled=<>
...@@ -70,19 +79,28 @@ English, please click [here](./HELP.md) ...@@ -70,19 +79,28 @@ English, please click [here](./HELP.md)
- mock server将逐一匹配请求数据的每一个域,最后返回与请求数据匹配度最高的某一个测试数据中response域的值。 - mock server将逐一匹配请求数据的每一个域,最后返回与请求数据匹配度最高的某一个测试数据中response域的值。
- 即,不同的两个测试数据,可以仅有一个域的值不同;mock server进行精确匹配,返回匹配成功的那个数据的response域值。 - 即,不同的两个测试数据,可以仅有一个域的值不同;mock server进行精确匹配,返回匹配成功的那个数据的response域值。
- [数据格式参考](./mvcmock/backend.json) - [数据格式参考](./mvcmock/backend.json)
```
##### 用例参考 ##### 用例参考
```
N/A N/A
```
##### 优点 ##### 优点
```
- 测试数据支持动态修改,无需重启服务。 - 测试数据支持动态修改,无需重启服务。
- 返回结果支持参数化,即可以根据请求路径中的参数,动态替换返回结果中对应的参数的值。 - 返回结果支持参数化,即可以根据请求路径中的参数,动态替换返回结果中对应的参数的值。
- 适配客户端http/https请求模式并响应;HTTP协议支持HTTP1.X和HTTP2, HTTP2同时支持h2和h2c。 - 适配客户端http/https请求模式并响应;HTTP协议支持HTTP1.X和HTTP2, HTTP2同时支持h2和h2c。
- 如何在本机生成证书?使用命令: keytool -genkey -alias netty-reactor -storetype PKCS12 -keyalg RSA -keysize 2048 -keystore keystore.p12 -validity 3650 - 如何在本机生成证书?使用命令: keytool -genkey -alias netty-reactor -storetype PKCS12 -keyalg RSA -keysize 2048 -keystore keystore.p12 -validity 3650
- 相关参数应根据在application.properties内的定义进行修改 - 相关参数应根据在application.properties内的定义进行修改
- curl命令验证例子: curl -H "Content-Type:application/json" -X POST -d '{"user_id": "123", "coin":100, "success":1, "msg":"OK!" }' -v --http2-prior-knowledge http://172.22.17.74:8080/userservice/fedFromOrg/87654321 - curl命令验证例子: curl -H "Content-Type:application/json" -X POST -d '{"user_id": "123", "coin":100, "success":1, "msg":"OK!" }' -v --http2-prior-knowledge http://172.22.17.74:8080/userservice/fedFromOrg/87654321
```
### 1.2 RPC API的自动化测试
```
RPC形式的API,又以 gRPC 和 json-RPC 两种实现方式居多。
```
#### 1.2.1 [json-RPC](https://www.jsonrpc.org/specification) API的自动化测试
#### 1.2.2 [gRPC](https://grpc.io/) API的自动化测试
```
protoc -I=D:\sourceFromGit\chain33\types\proto -I=accountmanager\proto\ --java_out=D:\sourceFromGit\auto-test\src\test\resources\testdatacollection\common\chain33 accountmanager.proto
```
...@@ -3,6 +3,8 @@ plugins { ...@@ -3,6 +3,8 @@ plugins {
id 'io.spring.dependency-management' version '1.0.11.RELEASE' id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java' id 'java'
id 'io.qameta.allure' version '2.8.1' id 'io.qameta.allure' version '2.8.1'
id "com.google.protobuf" version "0.8.17"
id 'idea'
} }
group = 'com.fuzamei' group = 'com.fuzamei'
...@@ -36,6 +38,10 @@ dependencies { ...@@ -36,6 +38,10 @@ dependencies {
implementation 'io.qameta.allure:allure-rest-assured:2.14.0' implementation 'io.qameta.allure:allure-rest-assured:2.14.0'
implementation 'com.squareup.okhttp3:okhttp:4.9.0' implementation 'com.squareup.okhttp3:okhttp:4.9.0'
implementation 'io.qameta.allure:allure-okhttp3:2.14.0' implementation 'io.qameta.allure:allure-okhttp3:2.14.0'
implementation 'com.github.briandilley.jsonrpc4j:jsonrpc4j:1.6'
implementation 'com.google.protobuf:protobuf-java:3.17.3'
implementation 'com.google.protobuf:protobuf-java-util:3.17.3'
implementation 'net.devh:grpc-client-spring-boot-starter:2.12.0.RELEASE'
compileOnly 'org.projectlombok:lombok:1.18.20' compileOnly 'org.projectlombok:lombok:1.18.20'
testCompileOnly 'org.projectlombok:lombok:1.18.20' testCompileOnly 'org.projectlombok:lombok:1.18.20'
...@@ -75,3 +81,26 @@ allure { ...@@ -75,3 +81,26 @@ allure {
downloadLink = 'https://repo.maven.apache.org/maven2/io/qameta/allure/allure-commandline/2.8.1/allure-commandline-2.8.1.zip' downloadLink = 'https://repo.maven.apache.org/maven2/io/qameta/allure/allure-commandline/2.8.1/allure-commandline-2.8.1.zip'
} }
sourceSets {
main {
proto {
srcDir 'src/main/resources/proto'
}
}
}
protobuf {
protoc {
artifact = 'com.google.protobuf:protoc:3.17.3'
}
plugins {
grpc {
artifact = 'io.grpc:protoc-gen-grpc-java:1.39.0'
}
}
generatedFilesBaseDir = "$projectDir/src"
}
...@@ -60,9 +60,15 @@ public class H2RestMessageEventListener { ...@@ -60,9 +60,15 @@ public class H2RestMessageEventListener {
} }
catch (Exception ex){ catch (Exception ex){
log.error("HTTP2 REST message [{}] send failed as error: {}", tmpTarget, ex); log.error("HTTP2 REST message [{}] send failed as error: {}", tmpTarget, ex);
throwExceptionInFeatureTest(restfulMessageEntity, ex.toString()); throwExceptionInFeatureTest(h2RestMessageEntity, ex.toString());
return; return;
} }
} }
private void throwExceptionInFeatureTest(H2RestMessageEntity restfulMessageEntity, String exception){
if (restfulMessageEntity.getRequestBody() != null || restfulMessageEntity.getResponseBody() != null){
throw new AssertionError(exception);
}
}
} }
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -45,5 +45,10 @@ restassured.connecttimeout=10000 ...@@ -45,5 +45,10 @@ restassured.connecttimeout=10000
restassured.requesttimeout=10000 restassured.requesttimeout=10000
restassured.sockettimeout=10000 restassured.sockettimeout=10000
okhttp3.http2.maxtotalconnections=10
okhttp3.http2.connectionkeepaliveduration=10
okhttp3.http2.calltimeout=10
okhttp3.http2.connectiontimeout=10
mvcmock.response.path=mvcmock/backend.json mvcmock.response.path=mvcmock/backend.json
mvcmock.https=false mvcmock.https=false
\ No newline at end of file
grpc:
client:
chain33:
address: 'static://172.22.19.2:8802'
enableKeepAlive: true
keepAliveWithoutCalls: true
negotiationType: plaintext
\ No newline at end of file
syntax = "proto3";
import "transaction.proto";
package types;
option java_package = "com.fuzamei.autotest.rpc.proto";
message Accountmanager {
}
message AccountmanagerAction {
oneof value {
//注册
Register register = 1;
//重置公钥
ResetKey resetKey = 2;
//转账
Transfer transfer = 3;
//监管操作
Supervise supervise = 4;
//申请操作,预留接口
Apply apply = 5;
}
int32 ty = 6;
}
//注册
message Register {
string accountID = 1;
// string addr = 2;
}
//重置公钥
message ResetKey {
string accountID = 1;
string addr = 2;
}
//用户申请服务
message Apply {
string accountID = 1;
//操作, 1 撤销账户公钥重置, 2 锁定期结束后,执行重置公钥操作
int32 op = 2;
}
//合约内部账户之间转账
message Transfer {
//资产类型 及转账金额
Asset asset = 1;
// from账户
string fromAccountID = 2;
// to账户
string toAccountID = 3;
}
//管理员监管操作
message Supervise {
//账户名单
repeated string accountIDs = 1;
//操作, 1为冻结,2为解冻,3增加有效期,4为授权
int32 op = 2;
//0普通,后面根据业务需要可以自定义,有管理员授予不同的权限
int32 level = 3;
}
message account{
//账户名称
string accountID = 1;
//地址
string addr = 2;
//上一次公钥地址
string prevAddr = 3;
//账户状态 0 正常, 1表示冻结, 2表示锁定 3,过期注销
int32 status = 4;
//等级权限 0普通,后面根据业务需要可以自定义,有管理员授予不同的权限
int32 level = 5;
//注册时间
int64 createTime = 6;
//失效时间
int64 expireTime = 7;
//锁定时间
int64 lockTime = 8;
//主键索引
int64 index = 9;
}
message AccountReceipt{
account account = 1;
}
message ReplyAccountList {
repeated account accounts = 1;
string primaryKey = 2;
}
message TransferReceipt{
account FromAccount = 1;
account ToAccount = 2;
int64 index = 3;
}
//回执日志
message SuperviseReceipt{
repeated account accounts = 1;
int32 op = 2;
int64 index = 3;
}
message QueryExpiredAccounts{
string primaryKey = 1;
//第一次需要传入逾期时间,时间戳
int64 expiredTime = 2;
//单页返回多少条记录,默认返回10条
// 0降序,1升序,默认降序
int32 direction = 3;
}
message QueryAccountsByStatus{
//账户状态 1 正常, 2表示冻结, 3表示锁定
int32 status = 1;
// 主键索引
string primaryKey = 3;
// 0降序,1升序,默认降序
int32 direction = 5;
}
message QueryAccountByID {
string accountID = 1;
}
message QueryAccountByAddr {
string addr = 1;
}
message QueryBalanceByID {
string accountID = 1;
Asset asset = 2;
}
message balance {
int64 balance = 1;
int64 frozen = 2;
}
service accountmanager {
}
syntax = "proto3";
package types;
option java_package = "com.fuzamei.autotest.rpc.proto";
option go_package = "github.com/33cn/chain33/types";
message Reply {
bool isOk = 1;
bytes msg = 2;
}
message ReqString {
string data = 1;
}
message ReplyString {
string data = 1;
}
message ReplyStrings {
repeated string datas = 1;
}
message ReqInt {
int64 height = 1;
}
message Int64 {
int64 data = 1;
}
message ReqHash {
bytes hash = 1;
bool upgrade = 2;
}
message ReplyHash {
bytes hash = 1;
}
message ReqNil {}
message ReqHashes {
repeated bytes hashes = 1;
}
message ReplyHashes {
repeated bytes hashes = 1;
}
message KeyValue {
bytes key = 1;
bytes value = 2;
}
message TxHash {
string hash = 1;
}
message TimeStatus {
string ntpTime = 1;
string localTime = 2;
int64 diff = 3;
}
message ReqKey {
bytes key = 1;
}
message ReqRandHash {
string execName = 1;
int64 height = 2;
int64 blockNum = 3;
bytes hash = 4;
}
/**
*当前软件版本信息
*/
message VersionInfo {
string title = 1;
string app = 2;
string chain33 = 3;
string localDb = 4;
int32 chainID = 5;
}
syntax = "proto3";
import "common.proto";
package types;
option java_package = "com.fuzamei.autotest.rpc.proto";
option go_package = "github.com/33cn/chain33/types";
// assert transfer struct
message AssetsGenesis {
int64 amount = 2;
string returnAddress = 3;
}
message AssetsTransferToExec {
string cointoken = 1;
int64 amount = 2;
bytes note = 3;
string execName = 4;
string to = 5;
}
message AssetsWithdraw {
string cointoken = 1;
int64 amount = 2;
bytes note = 3;
string execName = 4;
string to = 5;
}
message AssetsTransfer {
string cointoken = 1;
int64 amount = 2;
bytes note = 3;
string to = 4;
}
message Asset {
string exec = 1;
string symbol = 2;
int64 amount = 3;
}
message CreateTx {
string to = 1;
int64 amount = 2;
int64 fee = 3;
bytes note = 4;
bool isWithdraw = 5;
bool isToken = 6;
string tokenSymbol = 7;
string execName = 8;
string execer = 9;
}
message ReWriteRawTx {
string tx = 1;
// bytes execer = 2;
string to = 3;
string expire = 4;
int64 fee = 5;
int32 index = 6;
}
message CreateTransactionGroup {
repeated string txs = 1;
}
message UnsignTx {
bytes data = 1;
}
// 支持构造多笔nobalance的交易 payAddr 可以支持 1. 地址 2. 私钥
message NoBalanceTxs {
repeated string txHexs = 1;
string payAddr = 2;
string privkey = 3;
string expire = 4;
}
// payAddr 可以支持 1. 地址 2. 私钥
message NoBalanceTx {
string txHex = 1;
string payAddr = 2;
string privkey = 3;
string expire = 4;
}
message Transaction {
bytes execer = 1;
bytes payload = 2;
Signature signature = 3;
int64 fee = 4;
int64 expire = 5;
//随机ID,可以防止payload 相同的时候,交易重复
int64 nonce = 6;
//对方地址,如果没有对方地址,可以为空
string to = 7;
int32 groupCount = 8;
bytes header = 9;
bytes next = 10;
int32 chainID = 11;
}
message Transactions {
repeated Transaction txs = 1;
}
// 环签名类型时,签名字段存储的环签名信息
message RingSignature {
repeated RingSignatureItem items = 1;
}
// 环签名中的一组签名数据
message RingSignatureItem {
repeated bytes pubkey = 1;
repeated bytes signature = 2;
}
//对于一个交易组中的交易,要么全部成功,要么全部失败
//这个要好好设计一下
//最好交易构成一个链条[prevhash].独立的交易构成链条
//只要这个组中有一个执行是出错的,那么就执行不成功
//三种签名支持
// ty = 1 -> secp256k1
// ty = 2 -> ed25519
// ty = 3 -> sm2
// ty = 4 -> OnetimeED25519
// ty = 5 -> RingBaseonED25519
message Signature {
int32 ty = 1;
bytes pubkey = 2;
//当ty为5时,格式应该用RingSignature去解析
bytes signature = 3;
}
message AddrOverview {
int64 reciver = 1;
int64 balance = 2;
int64 txCount = 3;
}
message ReqAddr {
string addr = 1;
//表示取所有/from/to/其他的hash列表
int32 flag = 2;
int32 count = 3;
int32 direction = 4;
int64 height = 5;
int64 index = 6;
}
message HexTx {
string tx = 1;
}
message ReplyTxInfo {
bytes hash = 1;
int64 height = 2;
int64 index = 3;
repeated Asset assets = 4;
}
message ReqTxList {
int64 count = 1;
}
message ReplyTxList {
repeated Transaction txs = 1;
}
message ReqGetMempool {
bool isAll = 1;
}
message ReqProperFee {
int32 txCount = 1;
int32 txSize = 2;
}
message ReplyProperFee {
int64 properFee = 1;
}
message TxHashList {
repeated bytes hashes = 1;
int64 count = 2;
repeated int64 expire = 3;
}
message ReplyTxInfos {
repeated ReplyTxInfo txInfos = 1;
}
message ReceiptLog {
int32 ty = 1;
bytes log = 2;
}
// ty = 0 -> error Receipt
// ty = 1 -> CutFee //cut fee ,bug exec not ok
// ty = 2 -> exec ok
message Receipt {
int32 ty = 1;
repeated KeyValue KV = 2;
repeated ReceiptLog logs = 3;
}
message ReceiptData {
int32 ty = 1;
repeated ReceiptLog logs = 3;
}
message TxResult {
int64 height = 1;
int32 index = 2;
Transaction tx = 3;
ReceiptData receiptdate = 4;
int64 blocktime = 5;
string actionName = 6;
}
message TransactionDetail {
Transaction tx = 1;
ReceiptData receipt = 2;
repeated bytes proofs = 3;
int64 height = 4;
int64 index = 5;
int64 blocktime = 6;
int64 amount = 7;
string fromaddr = 8;
string actionName = 9;
repeated Asset assets = 10;
repeated TxProof txProofs = 11;
bytes fullHash = 12;
}
message TransactionDetails {
repeated TransactionDetail txs = 1;
}
message ReqAddrs {
repeated string addrs = 1;
}
message ReqDecodeRawTransaction {
string txHex = 1;
}
message UserWrite {
string topic = 1;
string content = 2;
}
message UpgradeMeta {
bool starting = 1;
string version = 2;
int64 height = 3;
}
//通过交易hash获取交易列表,需要区分是短hash还是全hash值
message ReqTxHashList {
repeated string hashes = 1;
bool isShortHash = 2;
}
//使用多层merkle树之后的proof证明结构体
message TxProof {
repeated bytes proofs = 1;
uint32 index = 2;
bytes rootHash = 3;
}
// 指定交易哈希,查找是否存在
message ReqCheckTxsExist {
repeated bytes txHashes = 1;
}
message ReplyCheckTxsExist {
//对应请求序列存在标识数组,存在则true,否则false
repeated bool existFlags = 1;
//存在情况的总个数
uint32 existCount = 2;
}
@rpcTry
Feature: Account Management Contract Test
Scenario: Account registration by RPC interface
Given The testing RUNTIME data intelligent_contract.accountManager is ready
Then Create account in chain33 with dataNum accountManager.001 by gRPC interface
\ No newline at end of file
{
"accountManager.001": {
"accountID": "stringstring"
}
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment