Commit 2a492e0a authored by Jacob Vosmaer's avatar Jacob Vosmaer

Do not distribute redigomock

The redigomock package is GPLv2. Because I am unsure if this is
compatible with our own license and because we will have the GitLab
9.0 release soon I am adding this hack to keep redigomock in our test
suite but out of our sources.

Also: https://gitlab.com/gitlab-org/gitlab-workhorse/issues/114
parent 2926c874
...@@ -5,3 +5,4 @@ testdata/public ...@@ -5,3 +5,4 @@ testdata/public
/gitlab-zip-cat /gitlab-zip-cat
/gitlab-zip-metadata /gitlab-zip-metadata
/_build /_build
/vendor/github.com/rafaeljusto/redigomock
...@@ -2,6 +2,7 @@ PREFIX=/usr/local ...@@ -2,6 +2,7 @@ PREFIX=/usr/local
VERSION=$(shell git describe)-$(shell date -u +%Y%m%d.%H%M%S) VERSION=$(shell git describe)-$(shell date -u +%Y%m%d.%H%M%S)
BUILD_DIR = $(shell pwd) BUILD_DIR = $(shell pwd)
export GOPATH=${BUILD_DIR}/_build export GOPATH=${BUILD_DIR}/_build
export PATH:=${GOPATH}/bin:${PATH}
export GO15VENDOREXPERIMENT=1 export GO15VENDOREXPERIMENT=1
GOBUILD=go build -ldflags "-X main.Version=${VERSION}" GOBUILD=go build -ldflags "-X main.Version=${VERSION}"
PKG=gitlab.com/gitlab-org/gitlab-workhorse PKG=gitlab.com/gitlab-org/gitlab-workhorse
...@@ -28,11 +29,16 @@ ${BUILD_DIR}/_build: ...@@ -28,11 +29,16 @@ ${BUILD_DIR}/_build:
touch $@ touch $@
.PHONY: test .PHONY: test
test: clean-build clean-workhorse all test: clean-build clean-workhorse all govendor
go fmt ${PKG_ALL} | awk '{ print } END { if (NR > 0) { print "Please run go fmt"; exit 1 } }' go fmt ${PKG_ALL} | awk '{ print } END { if (NR > 0) { print "Please run go fmt"; exit 1 } }'
cd ${GOPATH}/src/${PKG} && govendor sync
go test ${PKG_ALL} go test ${PKG_ALL}
@echo SUCCESS @echo SUCCESS
.PHONY: govendor
govendor:
command -v govendor || go get github.com/kardianos/govendor
coverage: coverage:
go test -cover -coverprofile=test.coverage go test -cover -coverprofile=test.coverage
go tool cover -html=test.coverage -o coverage.html go tool cover -html=test.coverage -o coverage.html
......
Charles Law - @clawconduce
Maciej Galkowski - @szank
Zachery Moneypenny - @whazzmaster
# Change Log
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
## [2.0.0] - 2016-05-24
### Added
- Fuzzy matching for redigomock command arguments
- Make commands a property of a connection object, which allows to run tests in parallel
- Commands calls counters, which allows to identify unused mocked commands (thanks to @rylnd)
### Changed
- Improve error message adding argument suggestions
## [1.0.0] - 2015-04-23
### Added
- Support to mock commands taking into account the arguments or not
- Support to mock PubSub using a wait Go channel
- Support to multiple (sequentially returned) responses for single command
- Support to mock scripts
This diff is collapsed.
redigomock
==========
[![Build Status](https://travis-ci.org/rafaeljusto/redigomock.png?branch=master)](https://travis-ci.org/rafaeljusto/redigomock)
[![GoDoc](https://godoc.org/github.com/rafaeljusto/redigomock?status.png)](https://godoc.org/github.com/rafaeljusto/redigomock)
Easy way to unit test projects using [redigo library](https://github.com/garyburd/redigo) (Redis client in go). You can find the latest release [here](https://github.com/rafaeljusto/redigomock/releases).
install
-------
```
go get -u github.com/rafaeljusto/redigomock
```
usage
-----
Here is an example of using redigomock, for more information please check the [API documentation](https://godoc.org/github.com/rafaeljusto/redigomock).
```go
package main
import (
"fmt"
"github.com/garyburd/redigo/redis"
"github.com/rafaeljusto/redigomock"
)
type Person struct {
Name string `redis:"name"`
Age int `redis:"age"`
}
func RetrievePerson(conn redis.Conn, id string) (Person, error) {
var person Person
values, err := redis.Values(conn.Do("HGETALL", fmt.Sprintf("person:%s", id)))
if err != nil {
return person, err
}
err = redis.ScanStruct(values, &person)
return person, err
}
func main() {
// Simulate command result
conn := redigomock.NewConn()
cmd := conn.Command("HGETALL", "person:1").ExpectMap(map[string]string{
"name": "Mr. Johson",
"age": "42",
})
person, err := RetrievePerson(conn, "1")
if err != nil {
fmt.Println(err)
return
}
if conn.Stats(cmd) != 1 {
fmt.Println("Command was not used")
return
}
if person.Name != "Mr. Johson" {
fmt.Printf("Invalid name. Expected 'Mr. Johson' and got '%s'\n", person.Name)
return
}
if person.Age != 42 {
fmt.Printf("Invalid age. Expected '42' and got '%d'\n", person.Age)
return
}
// Simulate command error
conn.Clear()
cmd = conn.Command("HGETALL", "person:1").ExpectError(fmt.Errorf("Simulate error!"))
person, err = RetrievePerson(conn, "1")
if err == nil {
fmt.Println("Should return an error!")
return
}
if conn.Stats(cmd) != 1 {
fmt.Println("Command was not used")
return
}
fmt.Println("Success!")
}
```
mocking a subscription
----------------------
```go
func CreateSubscriptionMessage(data []byte) []interface{} {
values := []interface{}{}
values = append(values, interface{}([]byte("message")))
values = append(values, interface{}([]byte("chanName")))
values = append(values, interface{}(data))
return values
}
rconnSub := redigomock.NewConn()
// Setup the initial subscription message
values := []interface{}{}
values = append(values, interface{}([]byte("subscribe")))
values = append(values, interface{}([]byte("chanName")))
values = append(values, interface{}([]byte("1")))
cmd := rconnSub.Command("SUBSCRIBE", subKey).Expect(values)
rconnSub.ReceiveWait = true
// Add a response that will come back as a subscription message
rconnSub.AddSubscriptionMessage(CreateSubscriptionMessage([]byte("hello")))
//You need to send messages to rconnSub.ReceiveNow in order to get a response.
//Sending to this channel will block until receive, so do it in a goroutine
go func() {
rconnSub.ReceiveNow <- true //This unlocks the subscribe message
rconnSub.ReceiveNow <- true //This sends the "hello" message
}()
```
// Copyright 2014 Rafael Dantas Justo. All rights reserved.
// Use of this source code is governed by a GPL
// license that can be found in the LICENSE file.
package redigomock
import (
"fmt"
"reflect"
)
// Response struct that represents single response from `Do` call
type Response struct {
Response interface{} // Response to send back when this command/arguments are called
Error error // Error to send back when this command/arguments are called
}
// Cmd stores the registered information about a command to return it later
// when request by a command execution
type Cmd struct {
Name string // Name of the command
Args []interface{} // Arguments of the command
Responses []Response // Slice of returned responses
}
// cmdHash stores a unique identifier of the command
type cmdHash string
// equal verify if a command/argumets is related to a registered command
func equal(commandName string, args []interface{}, cmd *Cmd) bool {
if commandName != cmd.Name || len(args) != len(cmd.Args) {
return false
}
for pos := range cmd.Args {
if implementsFuzzy(cmd.Args[pos]) && implementsFuzzy(args[pos]) {
if reflect.TypeOf(cmd.Args[pos]) != reflect.TypeOf(args[pos]) {
return false
}
} else if implementsFuzzy(cmd.Args[pos]) || implementsFuzzy(args[pos]) {
return false
} else {
if reflect.DeepEqual(cmd.Args[pos], args[pos]) == false {
return false
}
}
}
return true
}
// match check if provided arguments can be matched with any registered
// commands
func match(commandName string, args []interface{}, cmd *Cmd) bool {
if commandName != cmd.Name || len(args) != len(cmd.Args) {
return false
}
for pos := range cmd.Args {
if implementsFuzzy(cmd.Args[pos]) {
if cmd.Args[pos].(FuzzyMatcher).Match(args[pos]) == false {
return false
}
} else if reflect.DeepEqual(cmd.Args[pos], args[pos]) == false {
return false
}
}
return true
}
// Expect sets a response for this command. Everytime a Do or Receive methods
// are executed for a registered command this response or error will be
// returned. Expect call returns a pointer to Cmd struct, so you can chain
// Expect calls. Chained responses will be returned on subsequent calls
// matching this commands arguments in FIFO order
func (c *Cmd) Expect(response interface{}) *Cmd {
c.Responses = append(c.Responses, Response{response, nil})
return c
}
// ExpectMap works in the same way of the Expect command, but has a key/value
// input to make it easier to build test environments
func (c *Cmd) ExpectMap(response map[string]string) *Cmd {
var values []interface{}
for key, value := range response {
values = append(values, []byte(key))
values = append(values, []byte(value))
}
c.Responses = append(c.Responses, Response{values, nil})
return c
}
// ExpectError allows you to force an error when executing a
// command/arguments
func (c *Cmd) ExpectError(err error) *Cmd {
c.Responses = append(c.Responses, Response{nil, err})
return c
}
// ExpectSlice make it easier to expect slice value
// e.g - HMGET command
func (c *Cmd) ExpectSlice(resp ...interface{}) *Cmd {
response := []interface{}{}
for _, r := range resp {
response = append(response, r)
}
c.Responses = append(c.Responses, Response{response, nil})
return c
}
// hash generates a unique identifier for the command
func (c Cmd) hash() cmdHash {
output := c.Name
for _, arg := range c.Args {
output += fmt.Sprintf("%v", arg)
}
return cmdHash(output)
}
// Copyright 2014 Rafael Dantas Justo. All rights reserved.
// Use of this source code is governed by a GPL
// license that can be found in the LICENSE file.
// Package redigomock is a mock for redigo library (redis client)
//
// Redigomock basically register the commands with the expected results in a internal global
// variable. When the command is executed via Conn interface, the mock will look to this global
// variable to retrieve the corresponding result.
//
// To start a mocked connection just do the following:
//
// c := redigomock.NewConn()
//
// Now you can inject it whenever your system needs a redigo.Conn because it satisfies all interface
// requirements. Before running your tests you need beyond of mocking the connection, registering
// the expected results. For that you can generate commands with the expected results.
//
// c.Command("HGETALL", "person:1").Expect("Person!")
// c.Command(
// "HMSET", []string{"person:1", "name", "John"},
// ).Expect("ok")
//
// As the Expect method from Command receives anything (interface{}), another method was created to
// easy map the result to your structure. For that use ExpectMap:
//
// c.Command("HGETALL", "person:1").ExpectMap(map[string]string{
// "name": "John",
// "age": 42,
// })
//
// You should also test the error cases, and you can do it in the same way of a normal result.
//
// c.Command("HGETALL", "person:1").ExpectError(fmt.Errorf("Low level error!"))
//
// Sometimes you will want to register a command regardless the arguments, and you can do it with
// the method GenericCommand (mainly with the HMSET).
//
// c.GenericCommand("HMSET").Expect("ok")
//
// All commands are registered in a global variable, so they will be there until all your test cases
// ends. So for good practice in test writing you should in the beginning of each test case clear
// the mock states.
//
// c.Clear()
//
// Let's see a full test example. Imagine a Person structure and a function that pick up this
// person in Redis using redigo library (file person.go):
//
// package person
//
// import (
// "fmt"
// "github.com/garyburd/redigo/redis"
// )
//
// type Person struct {
// Name string `redis:"name"`
// Age int `redis:"age"`
// }
//
// func RetrievePerson(conn redis.Conn, id string) (Person, error) {
// var person Person
//
// values, err := redis.Values(conn.Do("HGETALL", fmt.Sprintf("person:%s", id)))
// if err != nil {
// return person, err
// }
//
// err = redis.ScanStruct(values, &person)
// return person, err
// }
//
// Now we need to test it, so let's create the corresponding test with redigomock
// (fileperson_test.go):
//
// package person
//
// import (
// "github.com/rafaeljusto/redigomock"
// "testing"
// )
//
// func TestRetrievePerson(t *testing.T) {
// conn := redigomock.NewConn()
// cmd := conn.Command("HGETALL", "person:1").ExpectMap(map[string]string{
// "name": "Mr. Johson",
// "age": "42",
// })
//
// person, err := RetrievePerson(conn, "1")
// if err != nil {
// t.Fatal(err)
// }
//
// if conn.Stats(cmd) != 1 {
// t.Fatal("Command was not called!")
// }
//
// if person.Name != "Mr. Johson" {
// t.Errorf("Invalid name. Expected 'Mr. Johson' and got '%s'", person.Name)
// }
//
// if person.Age != 42 {
// t.Errorf("Invalid age. Expected '42' and got '%d'", person.Age)
// }
// }
//
// func TestRetrievePersonError(t *testing.T) {
// conn := redigomock.NewConn()
// conn.Command("HGETALL", "person:1").ExpectError(fmt.Errorf("Simulate error!"))
//
// person, err = RetrievePerson(conn, "1")
// if err == nil {
// t.Error("Should return an error!")
// }
// }
//
// When you use redis as a persistent list, then you might want to call the
// same redis command multiple times. For example:
//
// func PollForData(conn redis.Conn) error {
// var url string
// var err error
//
// for {
// if url, err = conn.Do("LPOP", "URLS"); err != nil {
// return err
// }
//
// go func(input string) {
// // do something with the input
// }(url)
// }
//
// panic("Shouldn't be here")
// }
//
// To test it, you can chain redis responses. Let's write a test case:
//
// func TestPollForData(t *testing.T) {
// conn := redigomock.NewConn()
// conn.Command("LPOP", "URLS").
// Expect("www.some.url.com").
// Expect("www.another.url.com").
// ExpectError(redis.ErrNil)
//
// if err := PollForData(conn); err != redis.ErrNil {
// t.Error("This should return redis nil Error")
// }
// }
//
// In the first iteration of the loop redigomock would return
// "www.some.url.com", then "www.another.url.com" and finally redis.ErrNil.
//
// Sometimes providing expected arguments to redigomock at compile time could
// be too constraining. Let's imagine you use redis hash sets to store some
// data, along with the timestamp of the last data update. Let's expand our
// Person struct:
//
// type Person struct {
// Name string `redis:"name"`
// Age int `redis:"age"`
// UpdatedAt uint64 `redis:updatedat`
// Phone string `redis:phone`
// }
//
// And add a function updating personal data (phone number for example).
// Please notice that the update timestamp can't be determined at compile time:
//
// func UpdatePersonalData(conn redis.Conn, id string, person Person) error {
// _, err := conn.Do("HMSET", fmt.Sprint("person:", id), "name", person.Name, "age", person.Age, "updatedat" , time.Now.Unix(), "phone" , person.Phone)
// return err
// }
//
// Unit test:
//
// func TestUpdatePersonalData(t *testing.T){
// redigomock.Clear()
//
// person := Person{
// Name : "A name",
// Age : 18
// Phone : "123456"
// }
//
// conn := redigomock.NewConn()
// conn.Commmand("HMSET", "person:1", "name", person.Name, "age", person.Age, "updatedat", redigomock.NewAnyInt(), "phone", person.Phone).Expect("OK!")
//
// err := UpdatePersonalData(conn, "1", person)
// if err != nil {
// t.Error("This shouldn't return any errors")
// }
// }
//
// As you can see at the position of current timestamp redigomock is told to
// match AnyInt struct created by NewAnyInt() method. AnyInt struct will match
// any integer passed to redigomock from the tested method. Please see
// fuzzyMatch.go file for more details.
package redigomock
package redigomock
import "reflect"
// FuzzyMatcher is an interface that exports exports one function. It can be
// passed to the Command as a argument. When the command is evaluated agains
// data provided in mock connection Do call, FuzzyMatcher will call Match on the
// argument and returns true if argument fulfils constraints set in concrete
// implementation
type FuzzyMatcher interface {
// Match takes an argument passed to mock connection Do call and check if
// it fulfills constraints set in concrete implementation of this interface
Match(interface{}) bool
}
// NewAnyInt returns a FuzzyMatcher instance matching any integer passed as an
// argument
func NewAnyInt() FuzzyMatcher {
return anyInt{}
}
// NewAnyDouble returns a FuzzyMatcher instance mathing any double passed as
// an argument
func NewAnyDouble() FuzzyMatcher {
return anyDouble{}
}
// NewAnyData returns a FuzzyMatcher instance matching every data passed as
// an arguments (returns true by default)
func NewAnyData() FuzzyMatcher {
return anyData{}
}
type anyInt struct{}
func (matcher anyInt) Match(input interface{}) bool {
switch input.(type) {
case int, int8, int16, int32, int64, uint8, uint16, uint32, uint64:
return true
default:
return false
}
}
type anyDouble struct{}
func (matcher anyDouble) Match(input interface{}) bool {
switch input.(type) {
case float32, float64:
return true
default:
return false
}
}
type anyData struct{}
func (matcher anyData) Match(input interface{}) bool {
return true
}
func implementsFuzzy(input interface{}) bool {
return reflect.TypeOf(input).Implements(reflect.TypeOf((*FuzzyMatcher)(nil)).Elem())
}
// Copyright 2014 Rafael Dantas Justo. All rights reserved.
// Use of this source code is governed by a GPL
// license that can be found in the LICENSE file.
package redigomock
import (
"crypto/sha1"
"encoding/hex"
"fmt"
"sync"
)
type queueElement struct {
commandName string
args []interface{}
}
// Conn is the struct that can be used where you inject the redigo.Conn on
// your project
type Conn struct {
SubResponses []Response // Queue responses for PubSub
ReceiveWait bool // When set to true, Receive method will wait for a value in ReceiveNow channel to proceed, this is useful in a PubSub scenario
ReceiveNow chan bool // Used to lock Receive method to simulate a PubSub scenario
CloseMock func() error // Mock the redigo Close method
ErrMock func() error // Mock the redigo Err method
FlushMock func() error // Mock the redigo Flush method
commands []*Cmd // Slice that stores all registered commands for each connection
queue []queueElement // Slice that stores all queued commands for each connection
stats map[cmdHash]int // Command calls counter
statsMut sync.RWMutex // Locks the stats so we don't get concurrent map writes
}
// NewConn returns a new mocked connection. Obviously as we are mocking we
// don't need any Redis connection parameter
func NewConn() *Conn {
return &Conn{
ReceiveNow: make(chan bool),
stats: make(map[cmdHash]int),
}
}
// Close can be mocked using the Conn struct attributes
func (c *Conn) Close() error {
if c.CloseMock == nil {
return nil
}
return c.CloseMock()
}
// Err can be mocked using the Conn struct attributes
func (c *Conn) Err() error {
if c.ErrMock == nil {
return nil
}
return c.ErrMock()
}
// Command register a command in the mock system using the same arguments of
// a Do or Send commands. It will return a registered command object where
// you can set the response or error
func (c *Conn) Command(commandName string, args ...interface{}) *Cmd {
cmd := &Cmd{
Name: commandName,
Args: args,
}
c.removeRelatedCommands(commandName, args)
c.commands = append(c.commands, cmd)
return cmd
}
// Script registers a command in the mock system just like Command method
// would do. The first argument is a byte array with the script text, next
// ones are the ones you would pass to redis Script.Do() method
func (c *Conn) Script(scriptData []byte, keyCount int, args ...interface{}) *Cmd {
h := sha1.New()
h.Write(scriptData)
sha1sum := hex.EncodeToString(h.Sum(nil))
newArgs := make([]interface{}, 2+len(args))
newArgs[0] = sha1sum
newArgs[1] = keyCount
copy(newArgs[2:], args)
return c.Command("EVALSHA", newArgs...)
}
// GenericCommand register a command without arguments. If a command with
// arguments doesn't match with any registered command, it will look for
// generic commands before throwing an error
func (c *Conn) GenericCommand(commandName string) *Cmd {
cmd := &Cmd{
Name: commandName,
}
c.removeRelatedCommands(commandName, nil)
c.commands = append(c.commands, cmd)
return cmd
}
// find will scan the registered commands, looking for the first command with
// the same name and arguments. If the command is not found nil is returned
func (c *Conn) find(commandName string, args []interface{}) *Cmd {
for _, cmd := range c.commands {
if match(commandName, args, cmd) {
return cmd
}
}
return nil
}
// removeRelatedCommands verify if a command is already registered, removing
// any command already registered with the same name and arguments. This
// should avoid duplicated mocked commands
func (c *Conn) removeRelatedCommands(commandName string, args []interface{}) {
var unique []*Cmd
for _, cmd := range c.commands {
// new array will contain only commands that are not related to the given
// one
if !equal(commandName, args, cmd) {
unique = append(unique, cmd)
}
}
c.commands = unique
}
// Clear removes all registered commands. Useful for connection reuse in test
// scenarios
func (c *Conn) Clear() {
c.statsMut.Lock()
defer c.statsMut.Unlock()
c.commands = []*Cmd{}
c.queue = []queueElement{}
c.stats = make(map[cmdHash]int)
}
// Do looks in the registered commands (via Command function) if someone
// matches with the given command name and arguments, if so the corresponding
// response or error is returned. If no registered command is found an error
// is returned
func (c *Conn) Do(commandName string, args ...interface{}) (reply interface{}, err error) {
// @whazzmaster: Ensures that a call to Do() flushes the command queue
//
// The redigo package ensures that a call to Do() will flush any commands
// that were queued via the Send() method, however a call to Do() on the
// mock does not empty the queued commands
for _, cmd := range c.queue {
if _, err = c.do(cmd.commandName, cmd.args...); err != nil {
return
}
}
c.queue = []queueElement{}
return c.do(commandName, args...)
}
func (c *Conn) do(commandName string, args ...interface{}) (reply interface{}, err error) {
cmd := c.find(commandName, args)
if cmd == nil {
// Didn't find a specific command, try to get a generic one
if cmd = c.find(commandName, nil); cmd == nil {
var msg string
for _, regCmd := range c.commands {
if commandName == regCmd.Name {
if len(msg) == 0 {
msg = ". Possible matches are with the arguments:"
}
msg += fmt.Sprintf("\n* %#v", regCmd.Args)
}
}
return nil, fmt.Errorf("command %s with arguments %#v not registered in redigomock library%s",
commandName, args, msg)
}
}
c.statsMut.Lock()
c.stats[cmd.hash()]++
c.statsMut.Unlock()
if len(cmd.Responses) == 0 {
return nil, nil
}
response := cmd.Responses[0]
cmd.Responses = cmd.Responses[1:]
return response.Response, response.Error
}
// Send stores the command and arguments to be executed later (by the Receive
// function) in a first-come first-served order
func (c *Conn) Send(commandName string, args ...interface{}) error {
c.queue = append(c.queue, queueElement{
commandName: commandName,
args: args,
})
return nil
}
// Flush can be mocked using the Conn struct attributes
func (c *Conn) Flush() error {
if c.FlushMock == nil {
return nil
}
return c.FlushMock()
}
func (c *Conn) AddSubscriptionMessage(msg interface{}) {
resp := Response{}
resp.Response = msg
c.SubResponses = append(c.SubResponses, resp)
}
// Receive will process the queue created by the Send method, only one item
// of the queue is processed by Receive call. It will work as the Do method
func (c *Conn) Receive() (reply interface{}, err error) {
if c.ReceiveWait {
<-c.ReceiveNow
}
if len(c.queue) == 0 {
if len(c.SubResponses) > 0 {
reply, err = c.SubResponses[0].Response, c.SubResponses[0].Error
c.SubResponses = c.SubResponses[1:]
return
}
return nil, fmt.Errorf("no more items")
}
commandName, args := c.queue[0].commandName, c.queue[0].args
cmd := c.find(commandName, args)
if cmd == nil {
// Didn't find a specific command, try to get a generic one
if cmd = c.find(commandName, nil); cmd == nil {
return nil, fmt.Errorf("command %s with arguments %#v not registered in redigomock library",
commandName, args)
}
}
c.statsMut.Lock()
c.stats[cmd.hash()]++
c.statsMut.Unlock()
if len(cmd.Responses) == 0 {
reply, err = nil, nil
} else {
response := cmd.Responses[0]
cmd.Responses = cmd.Responses[1:]
reply, err = response.Response, response.Error
}
c.queue = c.queue[1:]
return
}
// Stats returns the number of times that a command was called in the current
// connection
func (c Conn) Stats(cmd *Cmd) int {
c.statsMut.RLock()
defer c.statsMut.RUnlock()
return c.stats[cmd.hash()]
}
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