Skip to content

Commit 36049ab

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
Less abstraction for integration tests
1 parent e1576dc commit 36049ab

File tree

4 files changed

+160
-82
lines changed

4 files changed

+160
-82
lines changed

Makefile

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ integration:
1515
@echo ""
1616
@echo "==> Checking if test database exists..."
1717
@if gcloud firestore databases describe --database=$(DS9_TEST_DATABASE) --project=$(DS9_TEST_PROJECT) >/dev/null 2>&1; then \
18-
echo " Database already exists, skipping creation"; \
18+
echo " Database already exists, using existing database"; \
1919
else \
2020
echo " Database does not exist, creating..."; \
2121
if ! gcloud firestore databases create --database=$(DS9_TEST_DATABASE) \
@@ -31,17 +31,21 @@ integration:
3131
exit 1; \
3232
fi; \
3333
echo " Database created successfully"; \
34+
echo " Waiting 10 seconds for database to propagate..."; \
35+
sleep 10; \
3436
fi
3537
@echo ""
3638
@echo "==> Running integration tests..."
37-
@DS9_TEST_PROJECT=$(DS9_TEST_PROJECT) go test -v -race -tags=integration -timeout=5m ./... || \
38-
(echo ""; echo "==> Tests failed, cleaning up..."; \
39-
gcloud firestore databases delete --database=$(DS9_TEST_DATABASE) --project=$(DS9_TEST_PROJECT) --quiet 2>/dev/null; \
40-
exit 1)
39+
@DS9_TEST_PROJECT=$(DS9_TEST_PROJECT) go test -v -race -timeout=5m ./...
4140
@echo ""
42-
@echo "==> Cleaning up test database..."
43-
@gcloud firestore databases delete --database=$(DS9_TEST_DATABASE) --project=$(DS9_TEST_PROJECT) --quiet
4441
@echo "==> Integration tests complete!"
42+
@echo "Note: Database $(DS9_TEST_DATABASE) is retained for reuse"
43+
@echo " Run 'make clean-integration-db' to delete it"
44+
45+
clean-integration-db:
46+
@echo "==> Deleting integration test database..."
47+
@gcloud firestore databases delete --database=$(DS9_TEST_DATABASE) --project=$(DS9_TEST_PROJECT) --quiet
48+
@echo "==> Database deleted successfully"
4549

4650
lint:
4751
go vet ./...

TESTING.md

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ Run the standard test suite with mock Datastore:
88
make test
99
```
1010

11-
This uses `ds9mock` for in-memory testing without requiring GCP credentials.
11+
This runs all tests including integration tests against an in-memory mock server. No GCP credentials required.
1212

1313
## Integration Tests
1414

15-
Integration tests run against real Google Cloud Datastore and automatically manage database lifecycle.
15+
Integration tests automatically use the mock server by default, but can run against real Google Cloud Datastore when `DS9_TEST_PROJECT` is set.
1616

1717
### Prerequisites
1818

@@ -40,9 +40,10 @@ make integration
4040
```
4141

4242
This will automatically:
43-
1. Create a temporary Datastore database (`ds9-test`)
44-
2. Run the full integration test suite
45-
3. Delete the temporary database (even if tests fail)
43+
1. Check if the test database (`ds9-test`) exists, or create it if needed
44+
2. If creating a new database, wait 10 seconds for it to propagate (GCP needs time to make the database available)
45+
3. Run the full integration test suite (including cleanup of test entities)
46+
4. Retain the database for reuse in subsequent test runs
4647

4748
**Customization:**
4849
```bash
@@ -56,29 +57,39 @@ make integration DS9_TEST_DATABASE=my-test-db
5657
make integration DS9_TEST_LOCATION=europe-west1
5758
```
5859

60+
### How It Works
61+
62+
- **Without `DS9_TEST_PROJECT`**: Tests run against an in-memory mock server (fast, no GCP needed)
63+
- **With `DS9_TEST_PROJECT`**: Tests run against real Google Cloud Datastore (requires GCP credentials)
64+
65+
The same test code runs in both modes, ensuring the mock accurately represents real Datastore behavior.
66+
5967
### What Gets Tested
6068

6169
- **Basic Operations**: Put, Get, Update, Delete
6270
- **Batch Operations**: PutMulti, GetMulti, DeleteMulti
6371
- **Transactions**: Read-modify-write operations
6472
- **Queries**: KeysOnly queries with limits
73+
- **Cleanup**: DeleteAllByKind operation
6574

6675
### Test Data
6776

68-
Test entities use the kind `DS9IntegrationTest` with unique timestamp-based names to avoid conflicts. The test database (`ds9-test`) is automatically created before tests and deleted after, ensuring complete cleanup.
77+
Test entities use the kind `DS9IntegrationTest` with unique timestamp-based names to avoid conflicts. Each test run creates entities, and the final cleanup test deletes all entities of this kind.
78+
79+
### Database Cleanup
6980

70-
### Manual Cleanup
81+
The test database is retained between test runs for performance (database creation takes several minutes). Test entities are automatically cleaned up at the end of each test run.
7182

72-
The Makefile automatically cleans up the test database, even if tests fail. If you need to manually clean up:
83+
To manually delete the test database:
7384

7485
```bash
75-
# List databases
76-
gcloud firestore databases list --project=integration-testing-476513
86+
# Delete test database
87+
make clean-integration-db
7788

78-
# Delete test database if it exists
89+
# Or use gcloud directly
7990
gcloud firestore databases delete --database=ds9-test --project=integration-testing-476513
8091
```
8192

8293
### Costs
8394

84-
Integration tests create a temporary database and a small number of entities (typically <20 per run). The database and all data are deleted after the test run completes. This should fall well within GCP free tier limits.
95+
Integration tests create or reuse a persistent database and a small number of entities (typically <20 per run). Entities are deleted after each test run. The persistent database incurs minimal costs and should fall well within GCP free tier limits.

datastore.go

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ func NewClientWithDatabase(ctx context.Context, projID, dbID string) (*Client, e
8787

8888
if projID == "" {
8989
logger.InfoContext(ctx, "project ID not provided, fetching from metadata server")
90-
pid, err := projectID(ctx)
90+
pid, err := auth.ProjectID(ctx)
9191
if err != nil {
9292
logger.ErrorContext(ctx, "failed to get project ID from metadata server", "error", err)
9393
return nil, fmt.Errorf("project ID required: %w", err)
@@ -105,11 +105,6 @@ func NewClientWithDatabase(ctx context.Context, projID, dbID string) (*Client, e
105105
}, nil
106106
}
107107

108-
// projectID retrieves the project ID using the auth package.
109-
func projectID(ctx context.Context) (string, error) {
110-
return auth.ProjectID(ctx)
111-
}
112-
113108
// accessToken retrieves an access token using the auth package.
114109
func accessToken(ctx context.Context) (string, error) {
115110
return auth.AccessToken(ctx)
@@ -220,26 +215,25 @@ func doRequest(ctx context.Context, logger *slog.Logger, url string, jsonData []
220215
"body_size", len(body),
221216
"attempt", attempt+1)
222217

218+
// Success
219+
if resp.StatusCode == http.StatusOK {
220+
return body, nil
221+
}
222+
223223
// Don't retry on 4xx errors (client errors)
224224
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
225225
if resp.StatusCode == http.StatusNotFound {
226226
logger.DebugContext(ctx, "entity not found", "status_code", resp.StatusCode)
227227
} else {
228228
logger.WarnContext(ctx, "client error", "status_code", resp.StatusCode, "body", string(body))
229229
}
230-
if resp.StatusCode != http.StatusOK {
231-
return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body))
232-
}
233-
return body, nil
230+
return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body))
234231
}
235232

236-
// Success
233+
// Unexpected 2xx/3xx status codes
237234
if resp.StatusCode < 400 {
238-
if resp.StatusCode != http.StatusOK {
239-
logger.WarnContext(ctx, "unexpected success status code", "status_code", resp.StatusCode)
240-
return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body))
241-
}
242-
return body, nil
235+
logger.WarnContext(ctx, "unexpected non-200 success status", "status_code", resp.StatusCode)
236+
return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body))
243237
}
244238

245239
// 5xx errors - retry
@@ -616,6 +610,34 @@ func (c *Client) DeleteMulti(ctx context.Context, keys []*Key) error {
616610
return nil
617611
}
618612

613+
// DeleteAllByKind deletes all entities of a given kind.
614+
// This method queries for all keys and then deletes them in batches.
615+
func (c *Client) DeleteAllByKind(ctx context.Context, kind string) error {
616+
c.logger.InfoContext(ctx, "deleting all entities by kind", "kind", kind)
617+
618+
// Query for all keys of this kind
619+
q := NewQuery(kind).KeysOnly()
620+
keys, err := c.AllKeys(ctx, q)
621+
if err != nil {
622+
c.logger.ErrorContext(ctx, "failed to query keys", "kind", kind, "error", err)
623+
return fmt.Errorf("failed to query keys: %w", err)
624+
}
625+
626+
if len(keys) == 0 {
627+
c.logger.InfoContext(ctx, "no entities found to delete", "kind", kind)
628+
return nil
629+
}
630+
631+
// Delete all keys
632+
if err := c.DeleteMulti(ctx, keys); err != nil {
633+
c.logger.ErrorContext(ctx, "failed to delete entities", "kind", kind, "count", len(keys), "error", err)
634+
return fmt.Errorf("failed to delete entities: %w", err)
635+
}
636+
637+
c.logger.InfoContext(ctx, "deleted all entities", "kind", kind, "count", len(keys))
638+
return nil
639+
}
640+
619641
// keyToJSON converts a Key to its JSON representation.
620642
// Supports hierarchical keys with parent relationships.
621643
func keyToJSON(key *Key) map[string]any {
@@ -631,17 +653,17 @@ func keyToJSON(key *Key) map[string]any {
631653
// Reverse to go from root to leaf
632654
for i := len(keys) - 1; i >= 0; i-- {
633655
k := keys[i]
634-
pathElement := map[string]any{
656+
elem := map[string]any{
635657
"kind": k.Kind,
636658
}
637659

638660
if k.Name != "" {
639-
pathElement["name"] = k.Name
661+
elem["name"] = k.Name
640662
} else if k.ID != 0 {
641-
pathElement["id"] = strconv.FormatInt(k.ID, 10)
663+
elem["id"] = strconv.FormatInt(k.ID, 10)
642664
}
643665

644-
path = append(path, pathElement)
666+
path = append(path, elem)
645667
}
646668

647669
return map[string]any{
@@ -996,14 +1018,18 @@ func keyFromJSON(keyData any) (*Key, error) {
9961018
}
9971019

9981020
// Transaction represents a Datastore transaction.
1021+
// Note: This struct stores context for API compatibility with Google's official
1022+
// cloud.google.com/go/datastore library, which uses the same pattern.
9991023
type Transaction struct {
1024+
ctx context.Context //nolint:containedctx // Required for API compatibility with cloud.google.com/go/datastore
10001025
client *Client
10011026
id string
10021027
mutations []map[string]any
10031028
}
10041029

10051030
// RunInTransaction runs a function in a transaction.
10061031
// The function should use the transaction's Get and Put methods.
1032+
// API compatible with cloud.google.com/go/datastore.
10071033
func (c *Client) RunInTransaction(ctx context.Context, f func(*Transaction) error) error {
10081034
const maxTxRetries = 3
10091035
var lastErr error
@@ -1067,6 +1093,7 @@ func (c *Client) RunInTransaction(ctx context.Context, f func(*Transaction) erro
10671093
}
10681094

10691095
tx := &Transaction{
1096+
ctx: ctx,
10701097
client: c,
10711098
id: txResp.Transaction,
10721099
}
@@ -1118,16 +1145,13 @@ func (c *Client) RunInTransaction(ctx context.Context, f func(*Transaction) erro
11181145
}
11191146

11201147
// Get retrieves an entity within the transaction.
1148+
// API compatible with cloud.google.com/go/datastore.
11211149
func (tx *Transaction) Get(key *Key, dst any) error {
11221150
if key == nil {
11231151
return errors.New("key cannot be nil")
11241152
}
11251153

1126-
// For simplicity, we'll use a context.Background() here
1127-
// In a production implementation, you might want to pass context through
1128-
ctx := context.Background()
1129-
1130-
token, err := accessToken(ctx)
1154+
token, err := accessToken(tx.ctx)
11311155
if err != nil {
11321156
return fmt.Errorf("failed to get access token: %w", err)
11331157
}
@@ -1151,7 +1175,7 @@ func (tx *Transaction) Get(key *Key, dst any) error {
11511175
}
11521176

11531177
url := fmt.Sprintf("%s/projects/%s:lookup", apiURL, tx.client.projectID)
1154-
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData))
1178+
req, err := http.NewRequestWithContext(tx.ctx, http.MethodPost, url, bytes.NewReader(jsonData))
11551179
if err != nil {
11561180
return err
11571181
}

0 commit comments

Comments
 (0)