Add registry module integration tests
This commit is contained in:
parent
5e47fae17e
commit
8748a3d473
7
tests/integration/data/examples/example1.yml
Normal file
7
tests/integration/data/examples/example1.yml
Normal file
@ -0,0 +1,7 @@
|
||||
record:
|
||||
attr1: value1
|
||||
attr2: value2
|
||||
link1:
|
||||
/: QmSnuWmxptJZdLJpKRarxBMS2Ju2oANVrgbr2xWbie9b2D
|
||||
link2:
|
||||
/: QmP8jTG1m9GSDJLCbeWhVSVgEzCPPwXRdCRuJtQ5Tz9Kc9
|
@ -0,0 +1,7 @@
|
||||
record:
|
||||
type: GeneralRecord
|
||||
name: foo
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- tagA
|
||||
- tagB
|
13
tests/integration/data/examples/service_provider_example.yml
Normal file
13
tests/integration/data/examples/service_provider_example.yml
Normal file
@ -0,0 +1,13 @@
|
||||
|
||||
record:
|
||||
type: ServiceProviderRegistration
|
||||
bond_id: madeUpBondID
|
||||
laconic_id: madeUpLaconicID
|
||||
version: 1.0.0
|
||||
x500:
|
||||
common_name: cerc-io
|
||||
organization_unit: xyz
|
||||
organization_name: abc
|
||||
state_name: california
|
||||
country: US
|
||||
locality_name: local
|
@ -0,0 +1,7 @@
|
||||
record:
|
||||
type: WebsiteRegistrationRecord
|
||||
url: https://cerc.io
|
||||
repo_registration_record_cid: QmSnuWmxptJZdLJpKRarxBMS2Ju2oANVrgbr2xWbie9b2D
|
||||
build_artifact_cid: QmP8jTG1m9GSDJLCbeWhVSVgEzCPPwXRdCRuJtQ5Tz9Kc9
|
||||
tls_cerc_cid: QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR
|
||||
version: 1.0.0
|
@ -3,10 +3,14 @@ package keeper_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cosmossdk.io/math"
|
||||
simtestutil "github.com/cosmos/cosmos-sdk/testutil/sims"
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
integrationTest "git.vdb.to/cerc-io/laconic2d/tests/integration"
|
||||
bondTypes "git.vdb.to/cerc-io/laconic2d/x/bond"
|
||||
types "git.vdb.to/cerc-io/laconic2d/x/registry"
|
||||
)
|
||||
|
||||
@ -15,6 +19,9 @@ type KeeperTestSuite struct {
|
||||
integrationTest.TestFixture
|
||||
|
||||
queryClient types.QueryClient
|
||||
|
||||
accounts []sdk.AccAddress
|
||||
bond bondTypes.Bond
|
||||
}
|
||||
|
||||
func (kts *KeeperTestSuite) SetupTest() {
|
||||
@ -26,8 +33,27 @@ func (kts *KeeperTestSuite) SetupTest() {
|
||||
|
||||
qr := kts.App.QueryHelper()
|
||||
kts.queryClient = types.NewQueryClient(qr)
|
||||
|
||||
// Create a bond
|
||||
bond, err := kts.createBond()
|
||||
assert.Nil(kts.T(), err)
|
||||
kts.bond = *bond
|
||||
}
|
||||
|
||||
func TestRegistryKeeperTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(KeeperTestSuite))
|
||||
}
|
||||
|
||||
func (kts *KeeperTestSuite) createBond() (*bondTypes.Bond, error) {
|
||||
ctx := kts.SdkCtx
|
||||
|
||||
// Create a funded account
|
||||
kts.accounts = simtestutil.AddTestAddrs(kts.BankKeeper, integrationTest.BondDenomProvider{}, ctx, 1, math.NewInt(100000000000))
|
||||
|
||||
bond, err := kts.BondKeeper.CreateBond(ctx, kts.accounts[0], sdk.NewCoins(sdk.NewCoin(sdk.DefaultBondDenom, math.NewInt(1000000000))))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bond, nil
|
||||
}
|
||||
|
@ -3,25 +3,420 @@ package keeper_test
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
|
||||
registrytypes "git.vdb.to/cerc-io/laconic2d/x/registry"
|
||||
types "git.vdb.to/cerc-io/laconic2d/x/registry"
|
||||
"git.vdb.to/cerc-io/laconic2d/x/registry/client/cli"
|
||||
"git.vdb.to/cerc-io/laconic2d/x/registry/helpers"
|
||||
registryKeeper "git.vdb.to/cerc-io/laconic2d/x/registry/keeper"
|
||||
)
|
||||
|
||||
func (kts *KeeperTestSuite) TestGrpcQueryParams() {
|
||||
testCases := []struct {
|
||||
msg string
|
||||
req *registrytypes.QueryParamsRequest
|
||||
req *types.QueryParamsRequest
|
||||
}{
|
||||
{
|
||||
"Get Params",
|
||||
®istrytypes.QueryParamsRequest{},
|
||||
&types.QueryParamsRequest{},
|
||||
},
|
||||
}
|
||||
for _, test := range testCases {
|
||||
kts.Run(fmt.Sprintf("Case %s ", test.msg), func() {
|
||||
resp, _ := kts.queryClient.Params(context.Background(), test.req)
|
||||
defaultParams := registrytypes.DefaultParams()
|
||||
defaultParams := types.DefaultParams()
|
||||
kts.Require().Equal(defaultParams.String(), resp.GetParams().String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (kts *KeeperTestSuite) TestGrpcGetRecordLists() {
|
||||
ctx, queryClient := kts.SdkCtx, kts.queryClient
|
||||
sr := kts.Require()
|
||||
|
||||
var recordId string
|
||||
examples := []string{
|
||||
"../../data/examples/service_provider_example.yml",
|
||||
"../../data/examples/website_registration_example.yml",
|
||||
"../../data/examples/general_record_example.yml",
|
||||
}
|
||||
testCases := []struct {
|
||||
msg string
|
||||
req *types.QueryRecordsRequest
|
||||
createRecords bool
|
||||
expErr bool
|
||||
noOfRecords int
|
||||
}{
|
||||
{
|
||||
"Empty Records",
|
||||
&types.QueryRecordsRequest{},
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"List Records",
|
||||
&types.QueryRecordsRequest{},
|
||||
true,
|
||||
false,
|
||||
3,
|
||||
},
|
||||
// TODO: Uncomment after implementing filtering by attributes
|
||||
// {
|
||||
// "Filter with type",
|
||||
// &types.QueryRecordsRequest{
|
||||
// Attributes: []*types.QueryRecordsRequest_KeyValueInput{
|
||||
// {
|
||||
// Key: "type",
|
||||
// Value: &types.QueryRecordsRequest_ValueInput{
|
||||
// Value: &types.QueryRecordsRequest_ValueInput_String_{String_: "WebsiteRegistrationRecord"},
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// All: true,
|
||||
// },
|
||||
// true,
|
||||
// false,
|
||||
// 1,
|
||||
// },
|
||||
// Skip the following test as querying with recursive values not supported (PR https://git.vdb.to/cerc-io/laconicd/pulls/112)
|
||||
// See function RecordsFromAttributes (QueryValueToJSON call) in the registry keeper implementation (x/registry/keeper/keeper.go)
|
||||
// {
|
||||
// "Filter with tag (extant) (https://git.vdb.to/cerc-io/laconicd/issues/129)",
|
||||
// &types.QueryRecordsRequest{
|
||||
// Attributes: []*types.QueryRecordsRequest_KeyValueInput{
|
||||
// {
|
||||
// Key: "tags",
|
||||
// // Value: &types.QueryRecordsRequest_ValueInput{
|
||||
// // Value: &types.QueryRecordsRequest_ValueInput_String_{"tagA"},
|
||||
// // },
|
||||
// Value: &types.QueryRecordsRequest_ValueInput{
|
||||
// Value: &types.QueryRecordsRequest_ValueInput_Array{Array: &types.QueryRecordsRequest_ArrayInput{
|
||||
// Values: []*types.QueryRecordsRequest_ValueInput{
|
||||
// {
|
||||
// Value: &types.QueryRecordsRequest_ValueInput_String_{"tagA"},
|
||||
// },
|
||||
// },
|
||||
// }},
|
||||
// },
|
||||
// // Throws: "Recursive query values are not supported"
|
||||
// },
|
||||
// },
|
||||
// All: true,
|
||||
// },
|
||||
// true,
|
||||
// false,
|
||||
// 1,
|
||||
// },
|
||||
// {
|
||||
// "Filter with tag (non-existent) (https://git.vdb.to/cerc-io/laconicd/issues/129)",
|
||||
// &types.QueryRecordsRequest{
|
||||
// Attributes: []*types.QueryRecordsRequest_KeyValueInput{
|
||||
// {
|
||||
// Key: "tags",
|
||||
// Value: &types.QueryRecordsRequest_ValueInput{
|
||||
// Value: &types.QueryRecordsRequest_ValueInput_String_{String_: "NOEXIST"},
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// All: true,
|
||||
// },
|
||||
// true,
|
||||
// false,
|
||||
// 0,
|
||||
// },
|
||||
// {
|
||||
// "Filter test for key collision (https://git.vdb.to/cerc-io/laconicd/issues/122)",
|
||||
// &types.QueryRecordsRequest{
|
||||
// Attributes: []*types.QueryRecordsRequest_KeyValueInput{
|
||||
// {
|
||||
// Key: "typ",
|
||||
// Value: &types.QueryRecordsRequest_ValueInput{
|
||||
// Value: &types.QueryRecordsRequest_ValueInput_String_{String_: "eWebsiteRegistrationRecord"},
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// All: true,
|
||||
// },
|
||||
// true,
|
||||
// false,
|
||||
// 0,
|
||||
// },
|
||||
// {
|
||||
// "Filter with attributes ServiceProviderRegistration",
|
||||
// &types.QueryRecordsRequest{
|
||||
// Attributes: []*types.QueryRecordsRequest_KeyValueInput{
|
||||
// {
|
||||
// Key: "x500state_name",
|
||||
// Value: &types.QueryRecordsRequest_ValueInput{
|
||||
// Value: &types.QueryRecordsRequest_ValueInput_String_{String_: "california"},
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// All: true,
|
||||
// },
|
||||
// true,
|
||||
// false,
|
||||
// 1,
|
||||
// },
|
||||
}
|
||||
for _, test := range testCases {
|
||||
kts.Run(fmt.Sprintf("Case %s ", test.msg), func() {
|
||||
if test.createRecords {
|
||||
for _, example := range examples {
|
||||
filePath, err := filepath.Abs(example)
|
||||
sr.NoError(err)
|
||||
payloadType, err := cli.GetPayloadFromFile(filePath)
|
||||
sr.NoError(err)
|
||||
payload := payloadType.ToPayload()
|
||||
record, err := kts.RegistryKeeper.SetRecord(ctx, types.MsgSetRecord{
|
||||
BondId: kts.bond.GetId(),
|
||||
Signer: kts.accounts[0].String(),
|
||||
Payload: payload,
|
||||
})
|
||||
sr.NoError(err)
|
||||
sr.NotNil(record.Id)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := queryClient.Records(context.Background(), test.req)
|
||||
|
||||
if test.expErr {
|
||||
kts.Error(err)
|
||||
} else {
|
||||
sr.NoError(err)
|
||||
sr.Equal(test.noOfRecords, len(resp.GetRecords()))
|
||||
if test.createRecords && test.noOfRecords > 0 {
|
||||
recordId = resp.GetRecords()[0].GetId()
|
||||
sr.NotZero(resp.GetRecords())
|
||||
sr.Equal(resp.GetRecords()[0].GetBondId(), kts.bond.GetId())
|
||||
|
||||
for _, record := range resp.GetRecords() {
|
||||
recAttr := helpers.MustUnmarshalJSON[types.AttributeMap](record.Attributes)
|
||||
|
||||
for _, attr := range test.req.GetAttributes() {
|
||||
enc, err := registryKeeper.QueryValueToJSON(attr.Value)
|
||||
sr.NoError(err)
|
||||
av := helpers.MustUnmarshalJSON[any](enc)
|
||||
|
||||
if nil != av && nil != recAttr[attr.Key] &&
|
||||
reflect.Slice == reflect.TypeOf(recAttr[attr.Key]).Kind() &&
|
||||
reflect.Slice != reflect.TypeOf(av).Kind() {
|
||||
found := false
|
||||
allValues := recAttr[attr.Key].([]interface{})
|
||||
for i := range allValues {
|
||||
if av == allValues[i] {
|
||||
fmt.Printf("Found %s in %s", allValues[i], recAttr[attr.Key])
|
||||
found = true
|
||||
}
|
||||
}
|
||||
sr.Equal(true, found, fmt.Sprintf("Unable to find %s in %s", av, recAttr[attr.Key]))
|
||||
} else {
|
||||
if attr.Key[:4] == "x500" {
|
||||
sr.Equal(av, recAttr["x500"].(map[string]interface{})[attr.Key[4:]])
|
||||
} else {
|
||||
sr.Equal(av, recAttr[attr.Key])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Get the records by record id
|
||||
testCases1 := []struct {
|
||||
msg string
|
||||
req *types.QueryRecordByIdRequest
|
||||
createRecord bool
|
||||
expErr bool
|
||||
noOfRecords int
|
||||
}{
|
||||
{
|
||||
"Invalid Request without record id",
|
||||
&types.QueryRecordByIdRequest{},
|
||||
false,
|
||||
true,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"With Record ID",
|
||||
&types.QueryRecordByIdRequest{
|
||||
Id: recordId,
|
||||
},
|
||||
true,
|
||||
false,
|
||||
1,
|
||||
},
|
||||
}
|
||||
for _, test := range testCases1 {
|
||||
kts.Run(fmt.Sprintf("Case %s ", test.msg), func() {
|
||||
resp, err := queryClient.GetRecord(context.Background(), test.req)
|
||||
|
||||
if test.expErr {
|
||||
kts.Error(err)
|
||||
} else {
|
||||
sr.NoError(err)
|
||||
sr.NotNil(resp.GetRecord())
|
||||
if test.createRecord {
|
||||
sr.Equal(resp.GetRecord().BondId, kts.bond.GetId())
|
||||
sr.Equal(resp.GetRecord().Id, recordId)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Get the records by record id
|
||||
testCasesByBondID := []struct {
|
||||
msg string
|
||||
req *types.QueryRecordsByBondIdRequest
|
||||
createRecord bool
|
||||
expErr bool
|
||||
noOfRecords int
|
||||
}{
|
||||
{
|
||||
"Invalid Request without bond id",
|
||||
&types.QueryRecordsByBondIdRequest{},
|
||||
false,
|
||||
true,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"With Bond ID",
|
||||
&types.QueryRecordsByBondIdRequest{
|
||||
Id: kts.bond.GetId(),
|
||||
},
|
||||
true,
|
||||
false,
|
||||
1,
|
||||
},
|
||||
}
|
||||
for _, test := range testCasesByBondID {
|
||||
kts.Run(fmt.Sprintf("Case %s ", test.msg), func() {
|
||||
resp, err := queryClient.GetRecordsByBondId(context.Background(), test.req)
|
||||
|
||||
if test.expErr {
|
||||
sr.Zero(resp.GetRecords())
|
||||
} else {
|
||||
sr.NoError(err)
|
||||
sr.NotNil(resp.GetRecords())
|
||||
if test.createRecord {
|
||||
sr.NotZero(resp.GetRecords())
|
||||
sr.Equal(resp.GetRecords()[0].GetBondId(), kts.bond.GetId())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (kts *KeeperTestSuite) TestGrpcQueryRegistryModuleBalance() {
|
||||
queryClient, ctx := kts.queryClient, kts.SdkCtx
|
||||
sr := kts.Require()
|
||||
examples := []string{
|
||||
"../../data/examples/service_provider_example.yml",
|
||||
"../../data/examples/website_registration_example.yml",
|
||||
}
|
||||
testCases := []struct {
|
||||
msg string
|
||||
req *types.QueryGetRegistryModuleBalanceRequest
|
||||
createRecords bool
|
||||
expErr bool
|
||||
noOfRecords int
|
||||
}{
|
||||
{
|
||||
"Get Module Balance",
|
||||
&types.QueryGetRegistryModuleBalanceRequest{},
|
||||
true,
|
||||
false,
|
||||
1,
|
||||
},
|
||||
}
|
||||
for _, test := range testCases {
|
||||
kts.Run(fmt.Sprintf("Case %s ", test.msg), func() {
|
||||
if test.createRecords {
|
||||
for _, example := range examples {
|
||||
filePath, err := filepath.Abs(example)
|
||||
sr.NoError(err)
|
||||
payloadType, err := cli.GetPayloadFromFile(filePath)
|
||||
sr.NoError(err)
|
||||
payload := payloadType.ToPayload()
|
||||
record, err := kts.RegistryKeeper.SetRecord(ctx, types.MsgSetRecord{
|
||||
BondId: kts.bond.GetId(),
|
||||
Signer: kts.accounts[0].String(),
|
||||
Payload: payload,
|
||||
})
|
||||
sr.NoError(err)
|
||||
sr.NotNil(record.Id)
|
||||
}
|
||||
}
|
||||
resp, err := queryClient.GetRegistryModuleBalance(context.Background(), test.req)
|
||||
if test.expErr {
|
||||
kts.Error(err)
|
||||
} else {
|
||||
sr.NoError(err)
|
||||
sr.Equal(test.noOfRecords, len(resp.GetBalances()))
|
||||
if test.createRecords {
|
||||
balance := resp.GetBalances()[0]
|
||||
sr.Equal(balance.AccountName, types.RecordRentModuleAccountName)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (kts *KeeperTestSuite) TestGrpcQueryWhoIs() {
|
||||
queryClient, ctx := kts.queryClient, kts.SdkCtx
|
||||
sr := kts.Require()
|
||||
authorityName := "TestGrpcQueryWhoIs"
|
||||
|
||||
testCases := []struct {
|
||||
msg string
|
||||
req *types.QueryWhoisRequest
|
||||
createName bool
|
||||
expErr bool
|
||||
noOfRecords int
|
||||
}{
|
||||
{
|
||||
"Invalid Request without name",
|
||||
&types.QueryWhoisRequest{},
|
||||
false,
|
||||
true,
|
||||
1,
|
||||
},
|
||||
// {
|
||||
// "Success",
|
||||
// &types.QueryWhoisRequest{},
|
||||
// true,
|
||||
// false,
|
||||
// 1,
|
||||
// },
|
||||
}
|
||||
for _, test := range testCases {
|
||||
kts.Run(fmt.Sprintf("Case %s ", test.msg), func() {
|
||||
if test.createName {
|
||||
err := kts.RegistryKeeper.ReserveAuthority(ctx, types.MsgReserveAuthority{
|
||||
Name: authorityName,
|
||||
Signer: kts.accounts[0].String(),
|
||||
Owner: kts.accounts[0].String(),
|
||||
})
|
||||
sr.NoError(err)
|
||||
test.req = &types.QueryWhoisRequest{Name: authorityName}
|
||||
}
|
||||
resp, err := queryClient.Whois(context.Background(), test.req)
|
||||
if test.expErr {
|
||||
kts.Error(err)
|
||||
sr.Nil(resp)
|
||||
} else {
|
||||
sr.NoError(err)
|
||||
if test.createName {
|
||||
nameAuth := resp.NameAuthority
|
||||
sr.NotNil(nameAuth)
|
||||
sr.Equal(nameAuth.OwnerAddress, kts.accounts[0].String())
|
||||
sr.Equal(types.AuthorityActive, nameAuth.Status)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -19,8 +19,10 @@ import (
|
||||
auth "github.com/cosmos/cosmos-sdk/x/auth/keeper"
|
||||
bank "github.com/cosmos/cosmos-sdk/x/bank/keeper"
|
||||
"github.com/gibson042/canonicaljson-go"
|
||||
cid "github.com/ipfs/go-cid"
|
||||
"github.com/ipld/go-ipld-prime"
|
||||
"github.com/ipld/go-ipld-prime/codec/dagjson"
|
||||
cidlink "github.com/ipld/go-ipld-prime/linking/cid"
|
||||
"github.com/ipld/go-ipld-prime/node/basicnode"
|
||||
|
||||
auctionkeeper "git.vdb.to/cerc-io/laconic2d/x/auction/keeper"
|
||||
@ -240,6 +242,55 @@ func (k Keeper) RecordsFromAttributes(ctx sdk.Context, attributes []*registrytyp
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
// TODO not recursive, and only should be if we want to support querying with whole sub-objects,
|
||||
// which seems unnecessary.
|
||||
func QueryValueToJSON(input *registrytypes.QueryRecordsRequest_ValueInput) ([]byte, error) {
|
||||
np := basicnode.Prototype.Any
|
||||
nb := np.NewBuilder()
|
||||
|
||||
switch value := input.GetValue().(type) {
|
||||
case *registrytypes.QueryRecordsRequest_ValueInput_String_:
|
||||
err := nb.AssignString(value.String_)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case *registrytypes.QueryRecordsRequest_ValueInput_Int:
|
||||
err := nb.AssignInt(value.Int)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case *registrytypes.QueryRecordsRequest_ValueInput_Float:
|
||||
err := nb.AssignFloat(value.Float)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case *registrytypes.QueryRecordsRequest_ValueInput_Boolean:
|
||||
err := nb.AssignBool(value.Boolean)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case *registrytypes.QueryRecordsRequest_ValueInput_Link:
|
||||
link := cidlink.Link{Cid: cid.MustParse(value.Link)}
|
||||
err := nb.AssignLink(link)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case *registrytypes.QueryRecordsRequest_ValueInput_Array:
|
||||
return nil, fmt.Errorf("recursive query values are not supported")
|
||||
case *registrytypes.QueryRecordsRequest_ValueInput_Map:
|
||||
return nil, fmt.Errorf("recursive query values are not supported")
|
||||
default:
|
||||
return nil, fmt.Errorf("value has unexpected type %T", value)
|
||||
}
|
||||
|
||||
n := nb.Build()
|
||||
var buf bytes.Buffer
|
||||
if err := dagjson.Encode(n, &buf); err != nil {
|
||||
return nil, fmt.Errorf("encoding value to JSON failed: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// PutRecord - saves a record to the store.
|
||||
func (k Keeper) SaveRecord(ctx sdk.Context, record registrytypes.Record) error {
|
||||
return k.Records.Set(ctx, record.Id, record)
|
||||
|
Loading…
Reference in New Issue
Block a user