Merge pull request #15 from 30x/XAPID-641

Create a mock server for unit, perf, and load testing
diff --git a/.gitignore b/.gitignore
index 1e2283b..ac08ee2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,4 +4,5 @@
 vendor
 *.iml
 .idea
-*.lock
\ No newline at end of file
+*.lock
+cmd/mockServer/mockServer
diff --git a/apigeeSync_suite_test.go b/apigeeSync_suite_test.go
index 30f8dcb..e8deef3 100644
--- a/apigeeSync_suite_test.go
+++ b/apigeeSync_suite_test.go
@@ -4,17 +4,15 @@
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/gomega"
 
-	"encoding/json"
+	"io/ioutil"
+	"net/http/httptest"
+	"os"
+	"testing"
+	"time"
+
 	"github.com/30x/apid"
 	"github.com/30x/apid/factory"
 	"github.com/apigee-labs/transicator/common"
-	"io/ioutil"
-	"net/http"
-	"net/http/httptest"
-	"os"
-	"strconv"
-	"testing"
-	"time"
 )
 
 var (
@@ -23,14 +21,10 @@
 	testRouter apid.Router
 )
 
-const testScope = "bootstrap"
-
 var _ = BeforeSuite(func(done Done) {
-	var phase int
-
 	apid.Initialize(factory.DefaultServicesFactory())
 
-	config := apid.Config()
+	config = apid.Config()
 
 	var err error
 	tmpDir, err = ioutil.TempDir("", "api_test")
@@ -43,250 +37,36 @@
 	config.Set(configProxyServerBaseURI, testServer.URL)
 	config.Set(configSnapServerBaseURI, testServer.URL)
 	config.Set(configChangeServerBaseURI, testServer.URL)
-	config.Set(configApidClusterId, "apid_config_scope_0")
-	config.Set(configName, "testhost")
-
 	config.Set(configSnapshotProtocol, "json")
-	config.Set(configApidClusterId, testScope)
+
+	config.Set(configName, "testhost")
+	config.Set(configApidClusterId, "bootstrap")
 	config.Set(configConsumerKey, "XXXXXXX")
 	config.Set(configConsumerSecret, "YYYYYYY")
 
-	// fake an unreliable api server - always fails the first time
-	fail := false
+	log = apid.Log()
 
-	// mock upstream testServer
-	testRouter.HandleFunc("/accesstoken", func(w http.ResponseWriter, req *http.Request) {
-		// make unreliable
-		fail = !fail
-		if fail {
-			w.WriteHeader(500)
-			return
-		}
-
-		Expect(req.Method).To(Equal("POST"))
-		Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded; param=value"))
-
-		err := req.ParseForm()
-		Expect(err).NotTo(HaveOccurred())
-		Expect(req.Form.Get("grant_type")).To(Equal("client_credentials"))
-		Expect(req.Header.Get("status")).To(Equal("ONLINE"))
-		Expect(req.Header.Get("apid_cluster_Id")).To(Equal("bootstrap"))
-		Expect(req.Header.Get("display_name")).To(Equal("testhost"))
-
-		var plugInfo []pluginDetail
-		plInfo := []byte(req.Header.Get("plugin_details"))
-		err = json.Unmarshal(plInfo, &plugInfo)
-		Expect(err).NotTo(HaveOccurred())
-
-		Expect(plugInfo[0].Name).To(Equal("apidApigeeSync"))
-		Expect(plugInfo[0].SchemaVersion).To(Equal("0.0.2"))
-
-		res := oauthTokenResp{}
-		res.AccessToken = "accesstoken"
-		body, err := json.Marshal(res)
-		Expect(err).NotTo(HaveOccurred())
-		w.Write(body)
-
-	}).Methods("POST")
-
-	testRouter.HandleFunc("/snapshots", func(w http.ResponseWriter, req *http.Request) {
-		// make unreliable
-		fail = !fail
-		if fail {
-			w.WriteHeader(500)
-			return
-		}
-
-		q := req.URL.Query()
-
-		if phase == 0 {
-			phase = 1
-			Expect(q.Get("scope")).To(Equal(testScope))
-			Expect(req.Header.Get("apid_cluster_Id")).To(Equal("bootstrap"))
-
-			apidcfgItem := common.Row{}
-			apidcfgItems := []common.Row{}
-			apidcfgItemCh := common.Row{}
-			apidcfgItemsCh := []common.Row{}
-			scv := &common.ColumnVal{
-				Value: testScope,
-				Type:  1,
-			}
-			apidcfgItem["id"] = scv
-			scv = &common.ColumnVal{
-				Value: testScope,
-				Type:  1,
-			}
-			apidcfgItem["_change_selector"] = scv
-			apidcfgItems = append(apidcfgItems, apidcfgItem)
-
-			scv = &common.ColumnVal{
-				Value: "apid_config_scope_id_0",
-				Type:  1,
-			}
-			apidcfgItemCh["id"] = scv
-
-			scv = &common.ColumnVal{
-				Value: "apid_config_scope_id_0",
-				Type:  1,
-			}
-			apidcfgItemCh["_change_selector"] = scv
-
-			scv = &common.ColumnVal{
-				Value: testScope,
-				Type:  1,
-			}
-			apidcfgItemCh["apid_cluster_id"] = scv
-
-			scv = &common.ColumnVal{
-				Value: "ert452",
-				Type:  1,
-			}
-			apidcfgItemCh["scope"] = scv
-
-			{
-				scv = &common.ColumnVal{
-					Value: "att",
-					Type:  1,
-				}
-				apidcfgItemCh["org"] = scv
-
-			}
-			{
-				scv = &common.ColumnVal{
-					Value: "prod",
-					Type:  1,
-				}
-				apidcfgItemCh["env"] = scv
-			}
-
-			apidcfgItemsCh = append(apidcfgItemsCh, apidcfgItemCh)
-
-			res := &common.Snapshot{}
-			res.SnapshotInfo = "snapinfo1"
-
-			res.Tables = []common.Table{
-				{
-					Name: "edgex.apid_cluster",
-					Rows: apidcfgItems,
-				},
-				{
-					Name: "edgex.data_scope",
-					Rows: apidcfgItemsCh,
-				},
-			}
-
-			body, err := json.Marshal(res)
-			Expect(err).NotTo(HaveOccurred())
-
-			w.Write(body)
-			return
-		} else {
-			phase = 2
-			scopes := q["scope"]
-			Expect(len(scopes)).Should(Equal(2))
-			Expect(scopes).To(ContainElement(testScope))
-			Expect(scopes).To(ContainElement("ert452"))
-			res := &common.Snapshot{}
-			res.SnapshotInfo = "snapinfo1"
-
-			apidcfgItems := []common.Row{}
-			res.Tables = []common.Table{
-				{
-					Name: "kms.api_product",
-					Rows: apidcfgItems,
-				},
-			}
-
-			body, err := json.Marshal(res)
-			Expect(err).NotTo(HaveOccurred())
-
-			w.Write(body)
-			return
-		}
-
-	}).Methods("GET")
-
-	testRouter.HandleFunc("/changes", func(w http.ResponseWriter, req *http.Request) {
-		fail = !fail
-		if fail {
-			w.WriteHeader(500)
-			return
-		}
-
-		if req.URL.Query().Get("since") == "lastSeq_01" {
-			go func() {
-				block, err := strconv.Atoi(req.URL.Query().Get("block"))
-				Expect(err).NotTo(HaveOccurred())
-				time.Sleep(time.Duration(block) * time.Second)
-				w.WriteHeader(http.StatusNotModified)
-			}()
-			return
-		}
-
-		Expect(req.Header.Get("apid_cluster_Id")).To(Equal("bootstrap"))
-		q := req.URL.Query()
-		Expect(q.Get("snapshot")).To(Equal("snapinfo1"))
-		scope := q["scope"]
-		Expect(scope).To(ContainElement("ert452"))
-		Expect(scope).To(ContainElement(testScope))
-
-		res := &common.ChangeList{}
-
-		res.LastSequence = "lastSeq_01"
-		mpItems := common.Row{}
-
-		scv := &common.ColumnVal{
-			Value: "apid_config_scope_id_1",
-			Type:  1,
-		}
-		mpItems["id"] = scv
-
-		scv = &common.ColumnVal{
-			Value: testScope,
-			Type:  1,
-		}
-		mpItems["apid_cluster_id"] = scv
-
-		scv = &common.ColumnVal{
-			Value: "ert452",
-			Type:  1,
-		}
-		mpItems["scope"] = scv
-		{
-			scv = &common.ColumnVal{
-				Value: "att",
-				Type:  1,
-			}
-			mpItems["org"] = scv
-		}
-		{
-			scv = &common.ColumnVal{
-				Value: "prod",
-				Type:  1,
-			}
-			mpItems["env"] = scv
-		}
-
-		res.Changes = []common.Change{
-			{
-				Table:     "edgex.data_scope",
-				NewRow:    mpItems,
-				Operation: 1,
-			},
-		}
-		body, err := json.Marshal(res)
-		Expect(err).NotTo(HaveOccurred())
-		w.Write(body)
-
-	}).Methods("GET")
+	// set up mock server
+	mockParms := MockParms{
+		ReliableAPI:  true,
+		ClusterID:    config.GetString(configApidClusterId),
+		TokenKey:     config.GetString(configConsumerKey),
+		TokenSecret:  config.GetString(configConsumerSecret),
+		Scope:        "ert452",
+		Organization: "att",
+		Environment:  "prod",
+	}
+	Mock(mockParms, testRouter)
 
 	// This is actually the first test :)
 	// Tests that entire bootstrap and set of sync operations work
+	var lastSnapshot *common.Snapshot
 	apid.Events().ListenFunc(ApigeeSyncEventSelector, func(event apid.Event) {
+		defer GinkgoRecover()
+
 		if s, ok := event.(*common.Snapshot); ok {
 
-			Expect(s.SnapshotInfo).Should(Equal("snapinfo1"))
+			lastSnapshot = s
 
 			for _, t := range s.Tables {
 				switch t.Name {
@@ -294,63 +74,55 @@
 				case "edgex.apid_cluster":
 					Expect(t.Rows).To(HaveLen(1))
 					r := t.Rows[0]
-					var cs, id string
-					r.Get("_change_selector", &cs)
+					var id string
 					r.Get("id", &id)
-
-					Expect(cs).To(Equal("bootstrap"))
 					Expect(id).To(Equal("bootstrap"))
 
 				case "edgex.data_scope":
-					Expect(t.Rows).To(HaveLen(1))
-					r := t.Rows[0]
+					Expect(t.Rows).To(HaveLen(2))
+					r := t.Rows[1] // get the non-cluster row
 
-					var cs, id, clusterID, env, org, scope string
-					r.Get("_change_selector", &cs)
+					var id, clusterID, env, org, scope string
 					r.Get("id", &id)
 					r.Get("apid_cluster_id", &clusterID)
 					r.Get("env", &env)
 					r.Get("org", &org)
 					r.Get("scope", &scope)
 
-					Expect(id).To(Equal("apid_config_scope_id_0"))
-					Expect(cs).To(Equal("apid_config_scope_id_0"))
+					Expect(id).To(Equal("ert452"))
+					Expect(scope).To(Equal("ert452"))
 					Expect(clusterID).To(Equal("bootstrap"))
 					Expect(env).To(Equal("prod"))
 					Expect(org).To(Equal("att"))
-					Expect(scope).To(Equal("ert452"))
-
-				case "kms.api_product":
-					Expect(t.Rows).To(HaveLen(0))
-
-				default:
-					Fail("invalid table: " + t.Name)
 				}
 			}
 
 		} else if cl, ok := event.(*common.ChangeList); ok {
 
-			Expect(cl.LastSequence).To(Equal("lastSeq_01"))
-			Expect(cl.Changes).To(HaveLen(1))
+			// ensure that snapshot switched DB versions
+			Expect(apidInfo.LastSnapshot).To(Equal(lastSnapshot.SnapshotInfo))
+			expectedDB, err := data.DBVersion(lastSnapshot.SnapshotInfo)
+			Expect(err).NotTo(HaveOccurred())
+			Expect(getDB() == expectedDB).Should(BeTrue())
 
-			c := cl.Changes[0]
-			Expect(c.Table).To(Equal("edgex.data_scope"))
-			Expect(c.Operation).To(Equal(common.Insert))
+			Expect(cl.Changes).To(HaveLen(6))
 
-			Expect(c.NewRow).ToNot(BeNil())
+			var tables []string
+			for _, c := range cl.Changes {
+				tables = append(tables, c.Table)
+				Expect(c.NewRow).ToNot(BeNil())
 
-			var id, clusterID, env, org, scope string
-			c.NewRow.Get("id", &id)
-			c.NewRow.Get("apid_cluster_id", &clusterID)
-			c.NewRow.Get("env", &env)
-			c.NewRow.Get("org", &org)
-			c.NewRow.Get("scope", &scope)
+				var tenantID string
+				c.NewRow.Get("tenant_id", &tenantID)
+				Expect(tenantID).To(Equal("ert452"))
+			}
 
-			Expect(id).To(Equal("apid_config_scope_id_1"))
-			Expect(clusterID).To(Equal("bootstrap"))
-			Expect(env).To(Equal("prod"))
-			Expect(org).To(Equal("att"))
-			Expect(scope).To(Equal("ert452"))
+			Expect(tables).To(ContainElement("kms.app_credential"))
+			Expect(tables).To(ContainElement("kms.app_credential_apiproduct_mapper"))
+			Expect(tables).To(ContainElement("kms.developer"))
+			Expect(tables).To(ContainElement("kms.company_developer"))
+			Expect(tables).To(ContainElement("kms.api_product"))
+			Expect(tables).To(ContainElement("kms.app"))
 
 			events.ListenFunc(apid.EventDeliveredSelector, func(e apid.Event) {
 				defer GinkgoRecover()
@@ -363,7 +135,7 @@
 					Scan(&seq)
 
 				Expect(err).NotTo(HaveOccurred())
-				Expect(seq).To(Equal("lastSeq_01"))
+				Expect(seq).To(Equal(cl.LastSequence))
 
 				close(done)
 			})
diff --git a/apigee_sync.go b/apigee_sync.go
index 3d59ddd..cecb838 100644
--- a/apigee_sync.go
+++ b/apigee_sync.go
@@ -4,13 +4,14 @@
 	"bytes"
 	"encoding/json"
 	"errors"
-	"github.com/30x/apid"
-	"github.com/apigee-labs/transicator/common"
 	"io/ioutil"
 	"net/http"
 	"net/url"
 	"path"
 	"time"
+
+	"github.com/30x/apid"
+	"github.com/apigee-labs/transicator/common"
 )
 
 var token string
@@ -31,7 +32,7 @@
 		if ev, ok := ede.Event.(*common.ChangeList); ok {
 			if lastSequence != ev.LastSequence {
 				lastSequence = ev.LastSequence
-				err := persistChange(lastSequence)
+				err := updateLastSequence(lastSequence)
 				if err != nil {
 					log.Panic("Unable to update Sequence in DB")
 				}
@@ -105,7 +106,7 @@
 	 * Check to see if we have lastSequence already saved in the DB,
 	 * in which case, it has to be used to prevent re-reading same data
 	 */
-	lastSequence = findApidConfigInfo(lastSequence)
+	lastSequence = getLastSequence()
 	for {
 		log.Debug("polling...")
 		if token == "" {
@@ -147,6 +148,8 @@
 			return err
 		}
 
+		// todo: should StatusNotChanged be a special case here?
+
 		/* If the call is not Authorized, update flag */
 		if r.StatusCode != http.StatusOK {
 			if r.StatusCode == http.StatusUnauthorized {
@@ -166,14 +169,6 @@
 			return err
 		}
 
-		if lastSequence != resp.LastSequence {
-			lastSequence = resp.LastSequence
-			err := persistChange(lastSequence)
-			if err != nil {
-				log.Panic("Unable to update Sequence in DB")
-			}
-		}
-
 		/* If valid data present, Emit to plugins */
 		if len(resp.Changes) > 0 {
 			changeFinished = false
@@ -201,7 +196,7 @@
 
 			if lastSequence != resp.LastSequence {
 				lastSequence = resp.LastSequence
-				err := persistChange(lastSequence)
+				err := updateLastSequence(lastSequence)
 				if err != nil {
 					log.Panic("Unable to update Sequence in DB")
 				}
@@ -210,7 +205,6 @@
 	}
 }
 
-
 // simple doubling back-off
 func createBackOff(retryIn, maxBackOff time.Duration) func() {
 	return func() {
@@ -318,7 +312,7 @@
 
 func Redirect(req *http.Request, via []*http.Request) error {
 	req.Header.Add("Authorization", "Bearer "+token)
-	req.Header.Add("org", apidInfo.ClusterID)
+	req.Header.Add("org", apidInfo.ClusterID) // todo: this is strange.. is it needed?
 	return nil
 }
 
@@ -339,9 +333,6 @@
 	// Skip Downloading snapshot if there is already a snapshot available from previous run of APID
 	if apidInfo.LastSnapshot != "" {
 
-		downloadDataSnapshot = true
-		downloadBootSnapshot = true
-
 		log.Infof("Starting on downloaded snapshot: %s", apidInfo.LastSnapshot)
 
 		// ensure DB version will be accessible on behalf of dependant plugins
@@ -354,7 +345,12 @@
 		snap := &common.Snapshot{
 			SnapshotInfo: apidInfo.LastSnapshot,
 		}
-		events.Emit(ApigeeSyncEventSelector, snap)
+		events.EmitWithCallback(ApigeeSyncEventSelector, snap, func(event apid.Event) {
+			downloadBootSnapshot = true
+			downloadDataSnapshot = true
+
+			go updatePeriodicChanges()
+		})
 
 		return
 	}
@@ -384,6 +380,8 @@
 	} else {
 		log.Panic("Snapshot for bootscope failed")
 	}
+
+	go updatePeriodicChanges()
 }
 
 func downloadSnapshot() {
diff --git a/cmd/mockServer/README.md b/cmd/mockServer/README.md
new file mode 100644
index 0000000..fd65ee5
--- /dev/null
+++ b/cmd/mockServer/README.md
@@ -0,0 +1,93 @@
+# apidApigeeSync Mock Server
+
+## Overview
+
+This Mock Server is used during unit tests of apidApigeeSync and has been designed to be run standalone
+for stand-alone development use as well as performance and load testing. 
+
+## Build
+
+From the apidApigeeSync base dir: 
+ 
+    glide install
+
+From apidApigeeSync/cmd/mockServer:
+
+    go build
+    
+You should now have an executable named "mockServer". 
+
+## Execute
+
+Execute with the -h flag to see flags: 
+
+    ./mockServer -h
+    Usage of ./mockServer:
+      -addDevEach duration
+            add a developer each duration (default 0s)
+      -bundleURI string
+            a URI to a valid deployment bundle (default '')
+      -numDeps int
+            number of deployments in snapshot (default 2)
+      -numDevs int
+            number of developers in snapshot (default 2)
+      -reliable
+            if false, server will often send 500 errors (default true)
+      -upDepEach duration
+            update (replace) a deployment each duration (default 0s)
+      -upDevEach duration
+            update a developer each duration (default 0s)         
+
+Note: Nothing is required. 
+
+The following are the values used by default by the Mock Server: 
+
+    ReliableAPI: true
+    ClusterID: "cluster"
+    TokenKey: "key"
+    TokenSecret: "secret"
+    Scope: "scope"
+    Organization: "org"
+    Environment: "test"
+    NumDevelopers: 2
+    AddDeveloperEvery: 0
+    UpdateDeveloperEvery: 0
+    NumDeployments: 2
+    ReplaceDeploymentEvery: 0
+    Port: 9001
+
+## Put it to use
+
+Set your apid configuration to point toward the Mock Server and have correct cluster, key, and secret values. 
+
+For example:
+
+    api_port: 9000
+    api_expvar_path: /expvar
+    events_buffer_size: 5
+    log_level: debug
+    apigeesync_proxy_server_base: http://localhost:9001
+    apigeesync_snapshot_server_base: http://localhost:9001
+    apigeesync_change_server_base: http://localhost:9001
+    apigeesync_consumer_key: key
+    apigeesync_consumer_secret: secret
+    apigeesync_cluster_id: cluster
+    #data_trace_log_level: debug
+    data_source: file:%s?_busy_timeout=20000
+
+Now start apid. It should download the snapshot and changes as you configured for the Mock Server.
+
+Try out a couple of APIs to verify:
+
+    curl -i -d "action=verify&key=1&uriPath=/&scopeuuid=scope" :9000/verifiers/apikey
+    
+    curl -i :9000/deployments
+
+## Notes
+
+Under high loads (eg. a large snapshot), apid may get timeout errors from sqlite. 
+If you see this, you can work around it by increasing the _busy_timeout by adding a config item to your apid config:
+
+    data_source: file:%s?_busy_timeout=10000
+
+The _busy_timeout value is in milliseconds, so the above value is 10s.
diff --git a/cmd/mockServer/main.go b/cmd/mockServer/main.go
new file mode 100644
index 0000000..cab43f5
--- /dev/null
+++ b/cmd/mockServer/main.go
@@ -0,0 +1,76 @@
+package main
+
+import (
+	"flag"
+
+	"os"
+
+	"time"
+
+	"github.com/30x/apid"
+	"github.com/30x/apid/factory"
+	"github.com/30x/apidApigeeSync"
+)
+
+// runs a mock server standalone
+func main() {
+	// create new flag to avoid displaying all the Ginkgo flags
+	flag := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
+
+	bundleURI := *flag.String("bundleURI", "", "a URI to a valid deployment bundle (default '')")
+
+	reliable := *flag.Bool("reliable", true, "if false, server will often send 500 errors")
+
+	numDevs := *flag.Int("numDevs", 2, "number of developers in snapshot")
+	addDevEach := *flag.Duration("addDevEach", 0*time.Second, "add a developer each duration (default 0s)")
+	upDevEach := *flag.Duration("upDevEach", 0*time.Second, "update a developer each duration (default 0s)")
+
+	numDeps := *flag.Int("numDeps", 2, "number of deployments in snapshot")
+	upDepEach := *flag.Duration("upDepEach", 0*time.Second, "update (replace) a deployment each duration (default 0s)")
+
+	flag.Parse(os.Args[1:])
+
+	apid.Initialize(factory.DefaultServicesFactory())
+
+	log := apid.Log()
+	log.Debug("initializing...")
+	apidApigeeSync.SetLogger(log)
+
+	config := apid.Config()
+	config.SetDefault("api_port", "9001")
+
+	router := apid.API().Router()
+
+	params := apidApigeeSync.MockParms{
+		ReliableAPI:            reliable,
+		ClusterID:              "cluster",
+		TokenKey:               "key",
+		TokenSecret:            "secret",
+		Scope:                  "scope",
+		Organization:           "org",
+		Environment:            "test",
+		NumDevelopers:          numDevs,
+		AddDeveloperEvery:      addDevEach,
+		UpdateDeveloperEvery:   upDevEach,
+		NumDeployments:         numDeps,
+		ReplaceDeploymentEvery: upDepEach,
+		BundleURI:              bundleURI,
+	}
+
+	log.Printf("Params: %#v\n", params)
+
+	apidApigeeSync.Mock(params, router)
+
+	// print the base url to the console
+	port := config.GetString("api_port")
+	log.Print()
+	log.Printf("API is at: http://localhost:%s", port)
+	log.Print()
+
+	// start client API listener
+	api := apid.API()
+	err := api.Listen()
+	if err != nil {
+		log.Print(err)
+	}
+}
diff --git a/data.go b/data.go
index 6618531..3ce623e 100644
--- a/data.go
+++ b/data.go
@@ -15,12 +15,12 @@
 )
 
 type dataApidCluster struct {
-	ChangeSelector, ID, Name, OrgAppName, CreatedBy, UpdatedBy, Description string
+	ID, Name, OrgAppName, CreatedBy, UpdatedBy, Description string
 	Updated, Created                                                        string
 }
 
 type dataDataScope struct {
-	ChangeSelector, ID, ClusterID, Scope, Org, Env, CreatedBy, UpdatedBy string
+	ID, ClusterID, Scope, Org, Env, CreatedBy, UpdatedBy string
 	Updated, Created                                                     string
 }
 
@@ -46,7 +46,6 @@
 	    created_by text,
 	    updated text,
 	    updated_by text,
-	    _change_selector text,
 	    last_sequence text,
 	    PRIMARY KEY (id)
 	);
@@ -60,7 +59,6 @@
 	    created_by text,
 	    updated text,
 	    updated_by text,
-	    _change_selector text,
 	    PRIMARY KEY (id, apid_cluster_id)
 	);
 	`)
@@ -91,9 +89,9 @@
 
 	stmt, err := txn.Prepare(`
 	INSERT INTO APID_CLUSTER
-		(id, _change_selector, name, umbrella_org_app_name,
+		(id, description, name, umbrella_org_app_name,
 		created, created_by, updated, updated_by,
-		description)
+		last_sequence)
 	VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9);
 	`)
 	if err != nil {
@@ -103,9 +101,9 @@
 	defer stmt.Close()
 
 	_, err = stmt.Exec(
-		dac.ID, dac.ChangeSelector, dac.Name, dac.OrgAppName,
+		dac.ID, dac.Description, dac.Name, dac.OrgAppName,
 		dac.Created, dac.CreatedBy, dac.Updated, dac.UpdatedBy,
-		dac.Description)
+		"")
 
 	if err != nil {
 		log.Errorf("insert APID_CLUSTER failed: %v", err)
@@ -122,8 +120,8 @@
 	INSERT INTO DATA_SCOPE
 		(id, apid_cluster_id, scope, org,
 		env, created, created_by, updated,
-		updated_by, _change_selector)
-	VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10);
+		updated_by)
+	VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9);
 	`)
 	if err != nil {
 		log.Errorf("insert DATA_SCOPE failed: %v", err)
@@ -134,7 +132,7 @@
 	_, err = stmt.Exec(
 		ds.ID, ds.ClusterID, ds.Scope, ds.Org,
 		ds.Env, ds.Created, ds.CreatedBy, ds.Updated,
-		ds.UpdatedBy, ds.ChangeSelector)
+		ds.UpdatedBy)
 
 	if err != nil {
 		log.Errorf("insert DATA_SCOPE failed: %v", err)
@@ -194,24 +192,15 @@
 /*
  * Retrieve SnapshotInfo for the given apidConfigId from apid_config table
  */
-func findApidConfigInfo(qparam string) (info string) {
+func getLastSequence() (lastSequence string) {
 
-	log.Debugf("findApidConfigInfo: %s", qparam)
-
-	db := getDB()
-
-	rows, err := db.Query("select ? from APID_CLUSTER", qparam)
-	if err != nil {
-		log.Errorf("Failed to query APID_CLUSTER: %v", err)
+	err := getDB().QueryRow("select last_sequence from APID_CLUSTER LIMIT 1").Scan(&lastSequence)
+	if err != nil && err != sql.ErrNoRows {
+		log.Panicf("Failed to query APID_CLUSTER: %v", err)
 		return
 	}
-	defer rows.Close()
-	for rows.Next() {
-		rows.Scan(&info)
-	}
 
-	log.Debugf("info: %s", info)
-
+	log.Debugf("lastSequence: %s", lastSequence)
 	return
 }
 
@@ -219,26 +208,24 @@
  * Persist the last change Id each time a change has been successfully
  * processed by the plugin(s)
  */
-func persistChange(lastChange string) error {
+func updateLastSequence(lastSequence string) error {
 
-	log.Debugf("persistChange: %s", lastChange)
+	log.Debugf("updateLastSequence: %s", lastSequence)
 
-	db := getDB()
-
-	stmt, err := db.Prepare("UPDATE APID_CLUSTER SET last_sequence=$1;")
+	stmt, err := getDB().Prepare("UPDATE APID_CLUSTER SET last_sequence=$1;")
 	if err != nil {
 		log.Errorf("UPDATE APID_CLUSTER Failed: %v", err)
 		return err
 	}
 	defer stmt.Close()
 
-	_, err = stmt.Exec(lastChange)
+	_, err = stmt.Exec(lastSequence)
 	if err != nil {
 		log.Errorf("UPDATE DATA_SCOPE Failed: %v", err)
 		return err
 	}
 
-	log.Infof("UPDATE DATA_SCOPE Success: %s", lastChange)
+	log.Infof("UPDATE DATA_SCOPE Success: %s", lastSequence)
 
 	return nil
 }
diff --git a/glide.yaml b/glide.yaml
index c07a74e..65d3e3d 100644
--- a/glide.yaml
+++ b/glide.yaml
@@ -2,7 +2,7 @@
 import:
 - package: github.com/30x/apid
   version: master
-testImport:
 - package: github.com/onsi/ginkgo/ginkgo
+  version: master
 - package: github.com/onsi/gomega
   version: master
diff --git a/init.go b/init.go
index e78b399..0b3d8c8 100644
--- a/init.go
+++ b/init.go
@@ -3,8 +3,9 @@
 import (
 	"encoding/json"
 	"fmt"
-	"github.com/30x/apid"
 	"os"
+
+	"github.com/30x/apid"
 )
 
 const (
@@ -50,6 +51,7 @@
 
 func initDefaults() {
 	config.SetDefault(configPollInterval, 120)
+	config.SetDefault(configSnapshotProtocol, "json")
 	name, errh := os.Hostname()
 	if (errh != nil) && (len(config.GetString(configName)) == 0) {
 		log.Errorf("Not able to get hostname for kernel. Please set '%s' property in config", configName)
@@ -87,6 +89,10 @@
 			return pluginData, fmt.Errorf("Missing required config value: %s", key)
 		}
 	}
+	proto := config.GetString(configSnapshotProtocol)
+	if proto != "json" && proto != "proto" {
+		return pluginData, fmt.Errorf("Illegal value for %s. Must be: 'json' or 'proto'", configSnapshotProtocol)
+	}
 
 	// set up default database
 	db, err := data.DB()
@@ -147,10 +153,6 @@
 
 		go bootstrap()
 
-		/* Begin Looking for changes periodically */
-		log.Debug("starting update goroutine")
-		go updatePeriodicChanges()
-
 		events.Listen(ApigeeSyncEventSelector, &handler{})
 		log.Debug("Done post plugin init")
 	}
diff --git a/listener.go b/listener.go
index 2a95d6f..bd4baee 100644
--- a/listener.go
+++ b/listener.go
@@ -59,7 +59,7 @@
 				ac := makeApidClusterFromRow(row)
 				err := insertApidCluster(ac, tx)
 				if err != nil {
-					log.Panic("Snapshot update failed: %v", err)
+					log.Panicf("Snapshot update failed: %v", err)
 				}
 			}
 
@@ -68,7 +68,7 @@
 				ds := makeDataScopeFromRow(row)
 				err := insertDataScope(ds, tx)
 				if err != nil {
-					log.Panic("Snapshot update failed: %v", err)
+					log.Panicf("Snapshot update failed: %v", err)
 				}
 			}
 		}
@@ -138,7 +138,6 @@
 	dac := dataApidCluster{}
 
 	row.Get("id", &dac.ID)
-	row.Get("_change_selector", &dac.ChangeSelector)
 	row.Get("name", &dac.Name)
 	row.Get("umbrella_org_app_name", &dac.OrgAppName)
 	row.Get("created", &dac.Created)
@@ -155,7 +154,6 @@
 	ds := dataDataScope{}
 
 	row.Get("id", &ds.ID)
-	row.Get("_change_selector", &ds.ChangeSelector)
 	row.Get("apid_cluster_id", &ds.ClusterID)
 	row.Get("scope", &ds.Scope)
 	row.Get("org", &ds.Org)
diff --git a/listener_test.go b/listener_test.go
index ad2c272..93a9588 100644
--- a/listener_test.go
+++ b/listener_test.go
@@ -55,7 +55,6 @@
 						Rows: []common.Row{
 							{
 								"id":                    &common.ColumnVal{Value: "i"},
-								"_change_selector":      &common.ColumnVal{Value: "c"},
 								"name":                  &common.ColumnVal{Value: "n"},
 								"umbrella_org_app_name": &common.ColumnVal{Value: "o"},
 								"created":               &common.ColumnVal{Value: "c"},
@@ -71,7 +70,6 @@
 						Rows: []common.Row{
 							{
 								"id":               &common.ColumnVal{Value: "i"},
-								"_change_selector": &common.ColumnVal{Value: "c"},
 								"apid_cluster_id":  &common.ColumnVal{Value: "a"},
 								"scope":            &common.ColumnVal{Value: "s"},
 								"org":              &common.ColumnVal{Value: "o"},
@@ -100,8 +98,7 @@
 
 			rows, err := db.Query(`
 			SELECT id, name, description, umbrella_org_app_name,
-				created, created_by, updated, updated_by,
-				_change_selector
+				created, created_by, updated, updated_by
 			FROM APID_CLUSTER`)
 			Expect(err).NotTo(HaveOccurred())
 			defer rows.Close()
@@ -109,8 +106,7 @@
 			c := dataApidCluster{}
 			for rows.Next() {
 				rows.Scan(&c.ID, &c.Name, &c.Description, &c.OrgAppName,
-					&c.Created, &c.CreatedBy, &c.Updated, &c.UpdatedBy,
-					&c.ChangeSelector)
+					&c.Created, &c.CreatedBy, &c.Updated, &c.UpdatedBy)
 				dcs = append(dcs, c)
 			}
 
@@ -125,7 +121,6 @@
 			Expect(dc.CreatedBy).To(Equal("c"))
 			Expect(dc.Updated).To(Equal("u"))
 			Expect(dc.UpdatedBy).To(Equal("u"))
-			Expect(dc.ChangeSelector).To(Equal("c"))
 
 			// Data Scope
 			var dds []dataDataScope
@@ -133,7 +128,7 @@
 			rows, err = db.Query(`
 			SELECT id, apid_cluster_id, scope, org,
 				env, created, created_by, updated,
-				updated_by, _change_selector
+				updated_by
 			FROM DATA_SCOPE`)
 			Expect(err).NotTo(HaveOccurred())
 			defer rows.Close()
@@ -142,7 +137,7 @@
 			for rows.Next() {
 				rows.Scan(&d.ID, &d.ClusterID, &d.Scope, &d.Org,
 					&d.Env, &d.Created, &d.CreatedBy, &d.Updated,
-					&d.UpdatedBy, &d.ChangeSelector)
+					&d.UpdatedBy)
 				dds = append(dds, d)
 			}
 
@@ -157,7 +152,6 @@
 			Expect(ds.CreatedBy).To(Equal("c"))
 			Expect(ds.Updated).To(Equal("u"))
 			Expect(ds.UpdatedBy).To(Equal("u"))
-			Expect(ds.ChangeSelector).To(Equal("c"))
 		})
 	})
 
@@ -209,7 +203,6 @@
 							Table:     LISTENER_TABLE_DATA_SCOPE,
 							NewRow: common.Row{
 								"id":               &common.ColumnVal{Value: "i"},
-								"_change_selector": &common.ColumnVal{Value: "c"},
 								"apid_cluster_id":  &common.ColumnVal{Value: "a"},
 								"scope":            &common.ColumnVal{Value: "s"},
 								"org":              &common.ColumnVal{Value: "o"},
@@ -230,7 +223,7 @@
 				rows, err := getDB().Query(`
 				SELECT id, apid_cluster_id, scope, org,
 					env, created, created_by, updated,
-					updated_by, _change_selector
+					updated_by
 				FROM DATA_SCOPE`)
 				Expect(err).NotTo(HaveOccurred())
 				defer rows.Close()
@@ -239,7 +232,7 @@
 				for rows.Next() {
 					rows.Scan(&d.ID, &d.ClusterID, &d.Scope, &d.Org,
 						&d.Env, &d.Created, &d.CreatedBy, &d.Updated,
-						&d.UpdatedBy, &d.ChangeSelector)
+						&d.UpdatedBy)
 					dds = append(dds, d)
 				}
 
@@ -254,7 +247,6 @@
 				Expect(ds.CreatedBy).To(Equal("c"))
 				Expect(ds.Updated).To(Equal("u"))
 				Expect(ds.UpdatedBy).To(Equal("u"))
-				Expect(ds.ChangeSelector).To(Equal("c"))
 			})
 
 			It("delete event should delete", func() {
@@ -266,7 +258,6 @@
 							Table:     LISTENER_TABLE_DATA_SCOPE,
 							NewRow: common.Row{
 								"id":               &common.ColumnVal{Value: "i"},
-								"_change_selector": &common.ColumnVal{Value: "c"},
 								"apid_cluster_id":  &common.ColumnVal{Value: "a"},
 								"scope":            &common.ColumnVal{Value: "s"},
 								"org":              &common.ColumnVal{Value: "o"},
diff --git a/mock_server.go b/mock_server.go
new file mode 100644
index 0000000..d8de52d
--- /dev/null
+++ b/mock_server.go
@@ -0,0 +1,672 @@
+package apidApigeeSync
+
+import (
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"hash/crc32"
+	"math/rand"
+	"net/http"
+	"net/url"
+	"strconv"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/30x/apid"
+	"github.com/apigee-labs/transicator/common"
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+/*
+Currently limited to:
+  1 cluster, 1 scope, 1 org, 1 env, 1 company
+  1 app & 1 product per developer
+
+Notes:
+  Scope ~= org + env
+  tenant_id == Scope for our purposes
+  (technically, data_scope.scope = tenant_id)
+
+Relations:
+  company => * developer
+  developer => * app
+  application => * app_credential
+  product => * app_credential
+*/
+
+type MockParms struct {
+	ReliableAPI            bool
+	ClusterID              string
+	TokenKey               string
+	TokenSecret            string
+	Scope                  string
+	Organization           string
+	Environment            string
+	NumDevelopers          int
+	AddDeveloperEvery      time.Duration
+	UpdateDeveloperEvery   time.Duration
+	NumDeployments         int
+	ReplaceDeploymentEvery time.Duration
+	BundleURI              string
+}
+
+func Mock(params MockParms, router apid.Router) *MockServer {
+	m := &MockServer{}
+	m.params = params
+
+	m.init()
+	m.registerRoutes(router)
+	return m
+}
+
+// table name -> common.Row
+type tableRowMap map[string]common.Row
+
+type MockServer struct {
+	params          MockParms
+	oauthToken      string
+	snapshotID      string
+	snapshotTables  map[string][]common.Table // key = scopeID
+	changeChannel   chan []byte
+	sequenceID      *int64
+	maxDevID        *int64
+	deployIDMutex   sync.RWMutex
+	minDeploymentID *int64
+	maxDeploymentID *int64
+}
+
+func (m *MockServer) lastSequenceID() string {
+	return strconv.FormatInt(atomic.LoadInt64(m.sequenceID), 10)
+}
+
+func (m *MockServer) nextSequenceID() string {
+	return strconv.FormatInt(atomic.AddInt64(m.sequenceID, 1), 10)
+}
+
+func (m *MockServer) nextDeveloperID() string {
+	return strconv.FormatInt(atomic.AddInt64(m.maxDevID, 1), 10)
+}
+
+func (m *MockServer) randomDeveloperID() string {
+	return strconv.FormatInt(rand.Int63n(atomic.LoadInt64(m.maxDevID)), 10)
+}
+
+func (m *MockServer) nextDeploymentID() string {
+	return strconv.FormatInt(atomic.AddInt64(m.maxDeploymentID, 1), 10)
+}
+
+func (m *MockServer) popDeploymentID() string {
+	newMinID := atomic.AddInt64(m.minDeploymentID, 1)
+	return strconv.FormatInt(newMinID-1, 10)
+}
+
+func (m *MockServer) init() {
+	defer GinkgoRecover()
+	RegisterFailHandler(func(message string, callerSkip ...int) {
+		log.Errorf("Expect error: %s", message)
+		panic(message)
+	})
+
+	m.sequenceID = new(int64)
+	m.maxDevID = new(int64)
+	m.changeChannel = make(chan []byte)
+	m.minDeploymentID = new(int64)
+	*m.minDeploymentID = 1
+	m.maxDeploymentID = new(int64)
+
+	go m.developerGenerator()
+	go m.developerUpdater()
+	go m.deploymentReplacer()
+
+	// cluster "scope"
+	cluster := m.newRow(map[string]string{
+		"id":               m.params.ClusterID,
+		"_change_selector": m.params.ClusterID,
+	})
+
+	// data scopes
+	var dataScopes []common.Row
+	dataScopes = append(dataScopes, cluster)
+	dataScopes = append(dataScopes, m.newRow(map[string]string{
+		"id":               m.params.Scope,
+		"scope":            m.params.Scope,
+		"org":              m.params.Organization,
+		"env":              m.params.Environment,
+		"apid_cluster_id":  m.params.ClusterID,
+		"_change_selector": m.params.Scope,
+	}))
+
+	// cluster & data_scope snapshot tables
+	m.snapshotTables = map[string][]common.Table{}
+	m.snapshotTables[m.params.ClusterID] = []common.Table{
+		{
+			Name: "edgex.apid_cluster",
+			Rows: []common.Row{cluster},
+		},
+		{
+			Name: "edgex.data_scope",
+			Rows: dataScopes,
+		},
+	}
+
+	var snapshotTableRows []tableRowMap
+
+	// generate one company
+	companyID := m.params.Organization
+	tenantID := m.params.Scope
+	changeSelector := m.params.Scope
+	company := tableRowMap{
+		"kms.company": m.newRow(map[string]string{
+			"id":               companyID,
+			"status":           "Active",
+			"tenant_id":        tenantID,
+			"name":             companyID,
+			"display_name":     companyID,
+			"_change_selector": changeSelector,
+		}),
+	}
+	snapshotTableRows = append(snapshotTableRows, company)
+
+	// generate snapshot developers
+	for i := 0; i < m.params.NumDevelopers; i++ {
+		developer := m.createDeveloperWithProductAndApp()
+		snapshotTableRows = append(snapshotTableRows, developer)
+	}
+	log.Infof("created %d developers", m.params.NumDevelopers)
+
+	// generate snapshot deployments
+	for i := 0; i < m.params.NumDeployments; i++ {
+		deployment := m.createDeployment()
+		snapshotTableRows = append(snapshotTableRows, deployment)
+	}
+	log.Infof("created %d deployments", m.params.NumDeployments)
+
+	m.snapshotTables[m.params.Scope] = m.concatTableRowMaps(snapshotTableRows...)
+
+	if m.params.NumDevelopers < 10 && m.params.NumDeployments < 10 {
+		log.Debugf("snapshotTables: %v", m.snapshotTables[m.params.Scope])
+	}
+}
+
+// developer, product, application, credential will have the same ID (developerID)
+func (m *MockServer) createDeveloperWithProductAndApp() tableRowMap {
+
+	developerID := m.nextDeveloperID()
+
+	devRows := m.createDeveloper(developerID)
+	productRows := m.createProduct(developerID)
+	appRows := m.createApplication(developerID, developerID, developerID, developerID)
+
+	return m.mergeTableRowMaps(devRows, productRows, appRows)
+}
+
+func (m *MockServer) registerRoutes(router apid.Router) {
+
+	router.HandleFunc("/accesstoken", m.unreliable(m.sendToken)).Methods("POST")
+	router.HandleFunc("/snapshots", m.unreliable(m.auth(m.sendSnapshot))).Methods("GET")
+	router.HandleFunc("/changes", m.unreliable(m.auth(m.sendChanges))).Methods("GET")
+	router.HandleFunc("/bundles/{id}", m.sendDeploymentBundle).Methods("GET")
+}
+
+func (m *MockServer) sendDeploymentBundle(w http.ResponseWriter, req *http.Request) {
+	vars := apid.API().Vars(req)
+	w.Write([]byte("/bundles/" + vars["id"]))
+}
+
+func (m *MockServer) sendToken(w http.ResponseWriter, req *http.Request) {
+	defer GinkgoRecover()
+	m.registerFailHandler(w)
+
+	Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded; param=value"))
+
+	err := req.ParseForm()
+	Expect(err).NotTo(HaveOccurred())
+	Expect(req.Form.Get("grant_type")).To(Equal("client_credentials"))
+	Expect(req.Header.Get("status")).To(Equal("ONLINE"))
+	Expect(req.Header.Get("apid_cluster_Id")).To(Equal(m.params.ClusterID))
+	Expect(req.Header.Get("display_name")).ToNot(BeEmpty())
+
+	Expect(req.Form.Get("client_id")).To(Equal(m.params.TokenKey))
+	Expect(req.Form.Get("client_secret")).To(Equal(m.params.TokenSecret))
+
+	var plugInfo []pluginDetail
+	plInfo := []byte(req.Header.Get("plugin_details"))
+	err = json.Unmarshal(plInfo, &plugInfo)
+	Expect(err).NotTo(HaveOccurred())
+
+	Expect(plugInfo[0].Name).To(Equal("apidApigeeSync"))
+	Expect(plugInfo[0].SchemaVersion).NotTo(BeEmpty())
+
+	m.oauthToken = generateUUID()
+	res := oauthTokenResp{
+		AccessToken: m.oauthToken,
+	}
+	body, err := json.Marshal(res)
+	Expect(err).NotTo(HaveOccurred())
+	w.Write(body)
+}
+
+func (m *MockServer) sendSnapshot(w http.ResponseWriter, req *http.Request) {
+	defer GinkgoRecover()
+	m.registerFailHandler(w)
+
+	q := req.URL.Query()
+	scopes := q["scope"]
+
+	Expect(scopes).To(ContainElement(m.params.ClusterID))
+
+	m.snapshotID = generateUUID()
+	snapshot := &common.Snapshot{
+		SnapshotInfo: m.snapshotID,
+	}
+
+	// Note: if/when we support multiple scopes, we'd have to do a merge of table rows
+	for _, scope := range scopes {
+		tables := m.snapshotTables[scope]
+		for _, table := range tables {
+			snapshot.AddTables(table)
+		}
+	}
+
+	body, err := json.Marshal(snapshot)
+	Expect(err).NotTo(HaveOccurred())
+
+	log.Info("sending snapshot")
+	if len(body) < 10000 {
+		log.Debugf("snapshot: %#v", string(body))
+	}
+
+	w.Write(body)
+}
+
+func (m *MockServer) sendChanges(w http.ResponseWriter, req *http.Request) {
+	defer GinkgoRecover()
+	m.registerFailHandler(w)
+
+	q := req.URL.Query()
+	scopes := q["scope"]
+	block, err := strconv.Atoi(req.URL.Query().Get("block"))
+	Expect(err).NotTo(HaveOccurred())
+	since := req.URL.Query().Get("since")
+
+	Expect(req.Header.Get("apid_cluster_Id")).To(Equal(m.params.ClusterID))
+	Expect(q.Get("snapshot")).To(Equal(m.snapshotID))
+
+	Expect(scopes).To(ContainElement(m.params.ClusterID))
+	//Expect(scopes).To(ContainElement(m.params.Scope))
+
+	if since != "" {
+		m.sendChange(w, time.Duration(block)*time.Second)
+		return
+	}
+
+	// todo: the following is just legacy for the existing test in apigeeSync_suite_test
+	developer := m.createDeveloperWithProductAndApp()
+	changeList := m.createInsertChange(developer)
+	body, err := json.Marshal(changeList)
+	if err != nil {
+		log.Errorf("Error generating developer: %v", err)
+	}
+	w.Write(body)
+}
+
+// generate developers w/ product and app
+func (m *MockServer) developerGenerator() {
+
+	for range time.Tick(m.params.AddDeveloperEvery) {
+
+		developer := m.createDeveloperWithProductAndApp()
+		changeList := m.createInsertChange(developer)
+
+		body, err := json.Marshal(changeList)
+		if err != nil {
+			log.Errorf("Error adding developer: %v", err)
+		}
+
+		log.Info("adding developer")
+		log.Debugf("body: %#v", string(body))
+		m.changeChannel <- body
+	}
+}
+
+// update random developers - set username
+func (m *MockServer) developerUpdater() {
+
+	for range time.Tick(m.params.UpdateDeveloperEvery) {
+
+		developerID := m.randomDeveloperID()
+
+		oldDev := m.createDeveloper(developerID)
+		delete(oldDev, "kms.company_developer")
+		newDev := m.createDeveloper(developerID)
+		delete(newDev, "kms.company_developer")
+
+		newRow := newDev["kms.developer"]
+		newRow["username"] = m.stringColumnVal("i_am_not_a_number")
+
+		changeList := m.createUpdateChange(oldDev, newDev)
+
+		body, err := json.Marshal(changeList)
+		if err != nil {
+			log.Errorf("Error updating developer: %v", err)
+		}
+
+		log.Info("updating developer")
+		log.Debugf("body: %#v", string(body))
+		m.changeChannel <- body
+	}
+}
+
+func (m *MockServer) deploymentReplacer() {
+
+	for range time.Tick(m.params.ReplaceDeploymentEvery) {
+
+		// delete
+		oldDep := tableRowMap{}
+		oldDep["edgex.deployment"] = m.newRow(map[string]string{
+			"id": m.popDeploymentID(),
+		})
+		deleteChange := m.createDeleteChange(oldDep)
+
+		// insert
+		newDep := m.createDeployment()
+		insertChange := m.createInsertChange(newDep)
+
+		changeList := m.concatChangeLists(deleteChange, insertChange)
+
+		body, err := json.Marshal(changeList)
+		if err != nil {
+			log.Errorf("Error replacing deployment: %v", err)
+		}
+
+		log.Info("replacing deployment")
+		log.Debugf("body: %#v", string(body))
+		m.changeChannel <- body
+	}
+}
+
+// todo: we could debounce this if necessary
+func (m *MockServer) sendChange(w http.ResponseWriter, timeout time.Duration) {
+	select {
+	case change := <-m.changeChannel:
+		log.Info("sending change to client")
+		w.Write(change)
+	case <-time.After(timeout):
+		log.Info("change request timeout")
+		w.WriteHeader(http.StatusNotModified)
+	}
+}
+
+// enforces handler auth
+func (m *MockServer) auth(target http.HandlerFunc) http.HandlerFunc {
+	return func(w http.ResponseWriter, req *http.Request) {
+		auth := req.Header.Get("Authorization")
+
+		if auth != fmt.Sprintf("Bearer %s", m.oauthToken) {
+			w.WriteHeader(http.StatusBadRequest)
+		} else {
+			target(w, req)
+		}
+	}
+}
+
+// make a handler unreliable
+func (m *MockServer) unreliable(target http.HandlerFunc) http.HandlerFunc {
+	if m.params.ReliableAPI {
+		return target
+	}
+
+	var fail bool
+	return func(w http.ResponseWriter, req *http.Request) {
+		fail = !fail
+		if fail {
+			w.WriteHeader(500)
+		} else {
+			target(w, req)
+		}
+	}
+}
+
+func (m *MockServer) registerFailHandler(w http.ResponseWriter) {
+	RegisterFailHandler(func(message string, callerSkip ...int) {
+		w.WriteHeader(400)
+		w.Write([]byte(message))
+		panic(message)
+	})
+}
+
+func (m *MockServer) newRow(keyAndVals map[string]string) (row common.Row) {
+
+	row = common.Row{}
+	for k, v := range keyAndVals {
+		row[k] = m.stringColumnVal(v)
+	}
+
+	// todo: remove this once apidVerifyAPIKey can deal with not having the field
+	row["_change_selector"] = m.stringColumnVal(m.params.Scope)
+
+	return
+}
+
+func (m *MockServer) stringColumnVal(v string) *common.ColumnVal {
+	return &common.ColumnVal{
+		Value: v,
+		Type:  1,
+	}
+}
+
+func (m *MockServer) createDeployment() tableRowMap {
+
+	deploymentID := m.nextDeploymentID()
+	bundleID := generateUUID()
+	port := apid.Config().GetString("api_port")
+
+	urlString := m.params.BundleURI
+	if urlString == "" {
+		urlString = fmt.Sprintf("http://localhost:%s/bundles/%s", port, bundleID)
+	}
+
+	uri, err := url.Parse(urlString)
+	Expect(err).NotTo(HaveOccurred())
+	hashWriter := crc32.NewIEEE()
+	hashWriter.Write([]byte(uri.Path))
+	checkSum := hex.EncodeToString(hashWriter.Sum(nil))
+
+	type bundleConfigJson struct {
+		Name         string `json:"name"`
+		URI          string `json:"uri"`
+		ChecksumType string `json:"checksumType"`
+		Checksum     string `json:"checksum"`
+	}
+
+	bundleJson, err := json.Marshal(bundleConfigJson{
+		Name:         uri.Path,
+		URI:          urlString,
+		ChecksumType: "crc-32",
+		Checksum:     checkSum,
+	})
+	Expect(err).ShouldNot(HaveOccurred())
+
+	rows := tableRowMap{}
+	rows["edgex.deployment"] = m.newRow(map[string]string{
+		"id":                 deploymentID,
+		"bundle_config_id":   bundleID,
+		"apid_cluster_id":    m.params.ClusterID,
+		"data_scope_id":      m.params.Scope,
+		"bundle_config_json": string(bundleJson),
+		"config_json":        "{}",
+	})
+
+	return rows
+}
+
+func (m *MockServer) createDeveloper(developerID string) tableRowMap {
+
+	companyID := m.params.Organization
+	tenantID := m.params.Scope
+
+	rows := tableRowMap{}
+
+	rows["kms.developer"] = m.newRow(map[string]string{
+		"id":        developerID,
+		"status":    "Active",
+		"tenant_id": tenantID,
+	})
+
+	// map developer onto to existing company
+	rows["kms.company_developer"] = m.newRow(map[string]string{
+		"id":           developerID,
+		"tenant_id":    tenantID,
+		"company_id":   companyID,
+		"developer_id": developerID,
+	})
+
+	return rows
+}
+
+func (m *MockServer) createProduct(productID string) tableRowMap {
+
+	tenantID := m.params.Scope
+
+	environments := fmt.Sprintf("{%s}", m.params.Environment)
+	resources := fmt.Sprintf("{%s}", "/") // todo: what should be here?
+
+	rows := tableRowMap{}
+	rows["kms.api_product"] = m.newRow(map[string]string{
+		"id":            productID,
+		"api_resources": resources,
+		"environments":  environments,
+		"tenant_id":     tenantID,
+	})
+	return rows
+}
+
+func (m *MockServer) createApplication(developerID, productID, applicationID, credentialID string) tableRowMap {
+
+	tenantID := m.params.Scope
+
+	rows := tableRowMap{}
+
+	rows["kms.app"] = m.newRow(map[string]string{
+		"id":           applicationID,
+		"developer_id": developerID,
+		"status":       "Approved",
+		"tenant_id":    tenantID,
+	})
+
+	rows["kms.app_credential"] = m.newRow(map[string]string{
+		"id":        credentialID,
+		"app_id":    applicationID,
+		"tenant_id": tenantID,
+		"status":    "Approved",
+	})
+
+	rows["kms.app_credential_apiproduct_mapper"] = m.newRow(map[string]string{
+		"apiprdt_id": productID,
+		"app_id":     applicationID,
+		"appcred_id": credentialID,
+		"status":     "Approved",
+		"tenant_id":  tenantID,
+	})
+
+	return rows
+}
+
+func (m *MockServer) createInsertChange(newRows tableRowMap) common.ChangeList {
+
+	var changeList = common.ChangeList{}
+	changeList.FirstSequence = m.lastSequenceID()
+	changeList.LastSequence = m.nextSequenceID()
+	for table, row := range newRows {
+		change := common.Change{
+			Table:     table,
+			NewRow:    row,
+			Operation: common.Insert,
+		}
+
+		changeList.Changes = append(changeList.Changes, change)
+	}
+	return changeList
+}
+
+func (m *MockServer) createDeleteChange(oldRows tableRowMap) common.ChangeList {
+
+	var changeList = common.ChangeList{}
+	changeList.FirstSequence = m.lastSequenceID()
+	changeList.LastSequence = m.nextSequenceID()
+	for table, row := range oldRows {
+		change := common.Change{
+			Table:     table,
+			OldRow:    row,
+			Operation: common.Delete,
+		}
+
+		changeList.Changes = append(changeList.Changes, change)
+	}
+	return changeList
+}
+
+func (m *MockServer) createUpdateChange(oldRows, newRows tableRowMap) common.ChangeList {
+
+	var changeList = common.ChangeList{}
+	changeList.FirstSequence = m.lastSequenceID()
+	changeList.LastSequence = m.nextSequenceID()
+	for table, oldRow := range oldRows {
+		change := common.Change{
+			Table:     table,
+			OldRow:    oldRow,
+			NewRow:    newRows[table],
+			Operation: common.Update,
+		}
+
+		changeList.Changes = append(changeList.Changes, change)
+	}
+	return changeList
+}
+
+// create one tableRowMap from various tableRowMap - tables must be unique
+func (m *MockServer) mergeTableRowMaps(maps ...tableRowMap) tableRowMap {
+	merged := tableRowMap{}
+	for _, m := range maps {
+		for name, row := range m {
+			if _, ok := merged[name]; ok {
+				panic(fmt.Sprintf("overwrite. name: %#v, row: %#v", name, row))
+			}
+			merged[name] = row
+		}
+	}
+	return merged
+}
+
+// create []common.Table from array of tableRowMaps
+func (m *MockServer) concatTableRowMaps(maps ...tableRowMap) []common.Table {
+	tableMap := map[string]*common.Table{}
+	for _, m := range maps {
+		for name, row := range m {
+			if _, ok := tableMap[name]; !ok {
+				tableMap[name] = &common.Table{
+					Name: name,
+				}
+			}
+			tableMap[name].AddRowstoTable(row)
+		}
+	}
+	result := []common.Table{}
+	for _, v := range tableMap {
+		result = append(result, *v)
+	}
+	return result
+}
+
+// create []common.Table from array of tableRowMaps
+func (m *MockServer) concatChangeLists(changeLists ...common.ChangeList) common.ChangeList {
+	result := common.ChangeList{}
+	for _, cl := range changeLists {
+		for _, c := range cl.Changes {
+			result.Changes = append(result.Changes, c)
+		}
+	}
+	return result
+}