ducky/devices

Paddy 2016-01-02 Parent:c24a6c5fcd8c

20:ed1b5ba69551 Go to Latest

ducky/devices/storer_test.go

Add updating devices to apiv1. We needed a way to be able to update devices after they were created. This is supported in the devices package, we just needed to expose it using apiv1 endpoints. In doing so, it became apparent that allowing users to change the Owner of their Devices wasn't properly thought through, and pending a reason to use it, I'm just removing it. The biggest issue came when trying to return usable error messages; we couldn't distinguish between "you don't own the device you're trying to update" and "you're not allowed to change the owner of the device". I also couldn't figure out _who should be able to_ change the owner of the device, which is generally an indication that I'm building a feature before I have a use case for it. To support this change, the apiv1.DeviceChange type needed its Owner property removed. I also needed to add deviceFromAPI and devicesFromAPI helpers to return devices.Device types from apiv1.Device types. There's now a new validateDeviceUpdate helper that checks to ensure that a device update request is valid and the user has the appropriate permissions. The createRequest type now accepts a slice of Devices, not a slice of DeviceChanges, because we want to pass the Owner in. A new updateRequest type is created, which accepts a DeviceChange to apply. A new handleUpdateDevice handler is created, which is assigned to the endpoint for PATCH requests against a device ID. It checks that the user is logged in, the Device they're trying to update exists, and that it's a valid update. If all of that is true, the device is updated and the updated device is returned. Finally, we had to add two new scopes to support new functionality: ScopeUpdateOtherUserDevices allows a user to update other user's devices, and ScopeUpdateLastSeen allows a user to update the LastSeen property of a device. Pending some better error messages, this should be a full implementation of updating a device, which leaves only the deletion endpoint to deal with.

History
paddy@2 1 package devices
paddy@2 2
paddy@2 3 import (
paddy@10 4 "fmt"
paddy@2 5 "testing"
paddy@2 6 "time"
paddy@2 7
paddy@2 8 "code.secondbit.org/uuid.hg"
paddy@2 9 "golang.org/x/net/context"
paddy@2 10 )
paddy@2 11
paddy@9 12 type StorerFactory interface {
paddy@9 13 NewStorer(ctx context.Context) (Storer, error)
paddy@9 14 TeardownStorer(storer Storer, ctx context.Context) error
paddy@9 15 }
paddy@9 16
paddy@9 17 var storerFactories []StorerFactory
paddy@2 18
paddy@10 19 const (
paddy@10 20 changeName = 1 << iota
paddy@10 21 changeOwner
paddy@10 22 changeType
paddy@10 23 changeLastSeen
paddy@10 24 changePushToken
paddy@10 25 changeVariations
paddy@10 26 )
paddy@10 27
paddy@2 28 func compareDevices(device1, device2 Device) (ok bool, field string, expected, result interface{}) {
paddy@2 29 if !device1.ID.Equal(device2.ID) {
paddy@2 30 return false, "ID", device1.ID, device2.ID
paddy@2 31 }
paddy@2 32 if device1.Name != device2.Name {
paddy@2 33 return false, "Name", device1.Name, device2.Name
paddy@2 34 }
paddy@2 35 if !device1.Owner.Equal(device2.Owner) {
paddy@2 36 return false, "Owner", device1.Owner, device2.Owner
paddy@2 37 }
paddy@2 38 if device1.Type != device2.Type {
paddy@2 39 return false, "Type", device1.Type, device2.Type
paddy@2 40 }
paddy@2 41 if !device1.Created.Equal(device2.Created) {
paddy@2 42 return false, "Created", device1.Created, device2.Created
paddy@2 43 }
paddy@2 44 if !device1.LastSeen.Equal(device2.LastSeen) {
paddy@2 45 return false, "LastSeen", device1.LastSeen, device2.LastSeen
paddy@2 46 }
paddy@2 47 if device1.PushToken != device2.PushToken {
paddy@2 48 return false, "PushToken", device1.PushToken, device2.PushToken
paddy@2 49 }
paddy@2 50 return true, "", nil, nil
paddy@2 51 }
paddy@2 52
paddy@5 53 func TestCreateAndGetDevices(t *testing.T) {
paddy@9 54 for _, factory := range storerFactories {
paddy@10 55 ctx := context.Background()
paddy@10 56 storer, err := factory.NewStorer(ctx)
paddy@2 57 if err != nil {
paddy@9 58 t.Fatalf("Fatal error creating Storer from %T: %+v\n", factory, err)
paddy@2 59 }
paddy@2 60
paddy@2 61 devices := []Device{
paddy@2 62 {ID: uuid.NewID(), Name: "Test 1", Owner: uuid.NewID(), Type: TypeAndroidPhone, Created: time.Now(), LastSeen: time.Now(), PushToken: "test token"},
paddy@2 63 {ID: uuid.NewID(), Name: "Test 2", Owner: uuid.NewID(), Type: TypeAndroidTablet, Created: time.Now(), LastSeen: time.Now(), PushToken: "test token"},
paddy@2 64 {ID: uuid.NewID(), Name: "Test 3", Owner: uuid.NewID(), Type: TypeChromeExtension, Created: time.Now(), LastSeen: time.Now(), PushToken: "test token"},
paddy@2 65 }
paddy@3 66
paddy@10 67 err = storer.CreateDevices(devices, ctx)
paddy@3 68 if err != nil {
paddy@5 69 t.Errorf("Error creating devices in %T: %+v\n", storer, err)
paddy@3 70 }
paddy@3 71
paddy@2 72 ids := make([]uuid.ID, 0, len(devices))
paddy@2 73 for _, device := range devices {
paddy@2 74 ids = append(ids, device.ID)
paddy@2 75 }
paddy@2 76
paddy@10 77 results, err := storer.GetDevices(ids, ctx)
paddy@2 78 if err != nil {
paddy@2 79 t.Errorf("Unexpected error retrieving devices from %T: %+v\n", storer, err)
paddy@2 80 }
paddy@2 81 for _, device := range devices {
paddy@2 82 d, returned := results[device.ID.String()]
paddy@2 83 if !returned {
paddy@2 84 t.Errorf("Expected device %s to be in results from %T, but wasn't present\n", device.Name, storer)
paddy@2 85 }
paddy@2 86 ok, field, expected, result := compareDevices(device, d)
paddy@2 87 if !ok {
paddy@2 88 t.Errorf("Expected %s of %s to be %v, got %v from %T\n", field, device.Name, expected, result, storer)
paddy@2 89 }
paddy@2 90 }
paddy@10 91 err = factory.TeardownStorer(storer, ctx)
paddy@2 92 if err != nil {
paddy@2 93 t.Errorf("Error cleaning up after %T: %+v\n", storer, err)
paddy@2 94 }
paddy@2 95 }
paddy@2 96 }
paddy@5 97
paddy@5 98 func TestGetDevicesNoErrorForMissing(t *testing.T) {
paddy@9 99 for _, factory := range storerFactories {
paddy@10 100 ctx := context.Background()
paddy@10 101 storer, err := factory.NewStorer(ctx)
paddy@5 102 if err != nil {
paddy@9 103 t.Fatalf("Fatal error creatng Storer from %T: %+v\n", factory, err)
paddy@5 104 }
paddy@5 105
paddy@10 106 results, err := storer.GetDevices([]uuid.ID{uuid.NewID()}, ctx)
paddy@5 107 if err != nil {
paddy@5 108 t.Errorf("Unexpected error retrieving devices from %T: %+v\n", storer, err)
paddy@5 109 }
paddy@5 110 if len(results) != 0 {
paddy@5 111 t.Errorf("Expected results to be empty, got %+v from %T instead\n", results, storer)
paddy@5 112 }
paddy@10 113 err = factory.TeardownStorer(storer, ctx)
paddy@5 114 if err != nil {
paddy@5 115 t.Errorf("Error cleaning up after %T: %+v\n", storer, err)
paddy@5 116 }
paddy@5 117 }
paddy@5 118 }
paddy@5 119
paddy@5 120 func TestCreateDevicesDuplicates(t *testing.T) {
paddy@9 121 for _, factory := range storerFactories {
paddy@10 122 ctx := context.Background()
paddy@10 123 storer, err := factory.NewStorer(ctx)
paddy@5 124 if err != nil {
paddy@9 125 t.Fatalf("Fatal error creating Storer from %T: %+v\n", factory, err)
paddy@5 126 }
paddy@5 127
paddy@5 128 devices := []Device{
paddy@5 129 {ID: uuid.NewID(), Name: "Test 1", Owner: uuid.NewID(), Type: TypeAndroidPhone, Created: time.Now(), LastSeen: time.Now(), PushToken: "test token"},
paddy@5 130 {ID: uuid.NewID(), Name: "Test 2", Owner: uuid.NewID(), Type: TypeAndroidTablet, Created: time.Now(), LastSeen: time.Now(), PushToken: "test token"},
paddy@5 131 {ID: uuid.NewID(), Name: "Test 3", Owner: uuid.NewID(), Type: TypeChromeExtension, Created: time.Now(), LastSeen: time.Now(), PushToken: "test token"},
paddy@5 132 }
paddy@5 133
paddy@10 134 err = storer.CreateDevices(devices, ctx)
paddy@5 135 if err != nil {
paddy@5 136 t.Errorf("Unexpected error creating devices in %T: %+v\n", storer, err)
paddy@5 137 }
paddy@5 138
paddy@5 139 newDevices := []Device{
paddy@5 140 {ID: uuid.NewID(), Name: "Test 4", Owner: uuid.NewID(), Type: TypeAndroidPhone, Created: time.Now(), LastSeen: time.Now(), PushToken: "test token"},
paddy@5 141 {ID: uuid.NewID(), Name: "Test 5", Owner: uuid.NewID(), Type: TypeAndroidPhone, Created: time.Now(), LastSeen: time.Now(), PushToken: "test token"},
paddy@5 142 }
paddy@5 143
paddy@10 144 err = storer.CreateDevices([]Device{newDevices[0], devices[1], newDevices[1]}, ctx)
paddy@5 145 daeErr, ok := err.(ErrDeviceAlreadyExists)
paddy@5 146 if !ok {
paddy@5 147 t.Errorf("Expected ErrDeviceAlreadyExists creating duplicate device in %T, got %+v\n", storer, err)
paddy@5 148 }
paddy@5 149 if !uuid.ID(daeErr).Equal(devices[1].ID) {
paddy@5 150 t.Errorf("Expected ErrDeviceAlreadyExists to be %+v, got %+v from %T\n", devices[1].ID, daeErr, storer)
paddy@5 151 }
paddy@5 152
paddy@5 153 // inserts should be a transaction; they either all make it, or none do
paddy@10 154 results, err := storer.GetDevices([]uuid.ID{newDevices[0].ID, newDevices[1].ID}, ctx)
paddy@5 155 if err != nil {
paddy@5 156 t.Errorf("Error retrieving devices from %T: %+v\n", storer, err)
paddy@5 157 }
paddy@5 158 if len(results) != 0 {
paddy@5 159 t.Errorf("Expected new inserts to not be in results, got %+v from %T\n", results, storer)
paddy@5 160 }
paddy@5 161
paddy@10 162 err = factory.TeardownStorer(storer, ctx)
paddy@5 163 if err != nil {
paddy@5 164 t.Errorf("Error cleaning up after %T: %+v\n", storer, err)
paddy@5 165 }
paddy@5 166 }
paddy@5 167 }
paddy@8 168
paddy@8 169 func TestCreateAndListDevicesByOwner(t *testing.T) {
paddy@9 170 for _, factory := range storerFactories {
paddy@10 171 ctx := context.Background()
paddy@10 172 storer, err := factory.NewStorer(ctx)
paddy@8 173 if err != nil {
paddy@9 174 t.Fatalf("Fatal error creating Storer from %T: %+v\n", factory, err)
paddy@8 175 }
paddy@8 176
paddy@8 177 owner1, owner2 := uuid.NewID(), uuid.NewID()
paddy@8 178 devices := []Device{
paddy@8 179 {ID: uuid.NewID(), Name: "Test 1", Owner: owner1, Type: TypeAndroidPhone, Created: time.Now(), LastSeen: time.Now(), PushToken: "test token"},
paddy@8 180 {ID: uuid.NewID(), Name: "Test 2", Owner: owner2, Type: TypeAndroidTablet, Created: time.Now(), LastSeen: time.Now(), PushToken: "test token"},
paddy@8 181 {ID: uuid.NewID(), Name: "Test 3", Owner: owner1, Type: TypeChromeExtension, Created: time.Now().Add(time.Minute), LastSeen: time.Now(), PushToken: "test token"},
paddy@8 182 }
paddy@8 183
paddy@10 184 err = storer.CreateDevices(devices, ctx)
paddy@8 185 if err != nil {
paddy@8 186 t.Errorf("Error creating devices in %T: %+v\n", storer, err)
paddy@8 187 }
paddy@8 188
paddy@10 189 results, err := storer.ListDevicesByOwner(owner1, ctx)
paddy@8 190 if err != nil {
paddy@8 191 t.Errorf("Error listing devices for owner1 from %T: %+v\n", storer, err)
paddy@8 192 }
paddy@8 193 if len(results) != 2 {
paddy@8 194 t.Errorf("Expected %d results for owner1, got %d from %T\n", 2, len(results), storer)
paddy@8 195 }
paddy@8 196 resultMap := ToMap(results)
paddy@8 197 d, ok := resultMap[devices[0].ID.String()]
paddy@8 198 if !ok {
paddy@8 199 t.Errorf("Expected to get %s in results, got %+v from %T\n", devices[0].Name, results, storer)
paddy@8 200 }
paddy@8 201 ok, field, expected, result := compareDevices(devices[0], d)
paddy@8 202 if !ok {
paddy@8 203 t.Errorf("Expected %s of %s to be %v, got %v from %T\n", field, devices[0].Name, expected, result, storer)
paddy@8 204 }
paddy@8 205 d, ok = resultMap[devices[2].ID.String()]
paddy@8 206 if !ok {
paddy@8 207 t.Errorf("Expected to get %s in results, got %+v from %T\n", devices[2].Name, results, storer)
paddy@8 208 }
paddy@8 209 ok, field, expected, result = compareDevices(devices[2], d)
paddy@8 210 if !ok {
paddy@8 211 t.Errorf("Expected %s of %s to be %v, got %v from %T\n", field, devices[2].Name, expected, result, storer)
paddy@8 212 }
paddy@8 213
paddy@10 214 results, err = storer.ListDevicesByOwner(owner2, ctx)
paddy@8 215 if err != nil {
paddy@8 216 t.Errorf("Error listing devices for owner2 from %T: %+v\n", storer, err)
paddy@8 217 }
paddy@8 218 if len(results) != 1 {
paddy@8 219 t.Errorf("Expected %d results for owner2, got %d from %T\n", 1, len(results), storer)
paddy@8 220 }
paddy@8 221 ok, field, expected, result = compareDevices(devices[1], results[0])
paddy@8 222 if !ok {
paddy@8 223 t.Errorf("Expected %s of %s to be %v, got %v from %T\n", field, devices[1].Name, expected, result, storer)
paddy@8 224 }
paddy@8 225
paddy@10 226 err = factory.TeardownStorer(storer, ctx)
paddy@8 227 if err != nil {
paddy@8 228 t.Errorf("Error cleaning up after %T: %+v\n", storer, err)
paddy@8 229 }
paddy@8 230 }
paddy@8 231 }
paddy@10 232
paddy@10 233 func TestUpdateDevicesHappyPath(t *testing.T) {
paddy@10 234 device := Device{
paddy@10 235 ID: uuid.NewID(),
paddy@10 236 Name: "Test 1",
paddy@10 237 Owner: uuid.NewID(),
paddy@10 238 Type: TypeAndroidPhone,
paddy@10 239 Created: time.Now(),
paddy@10 240 LastSeen: time.Now(),
paddy@10 241 PushToken: "test token",
paddy@10 242 }
paddy@10 243 for _, factory := range storerFactories {
paddy@10 244 ctx := context.Background()
paddy@10 245 storer, err := factory.NewStorer(ctx)
paddy@10 246 if err != nil {
paddy@10 247 t.Fatalf("Fatal error creating Storer from %T: %+v\n", factory, err)
paddy@10 248 }
paddy@10 249 for i := 1; i < changeVariations; i++ {
paddy@10 250 var change DeviceChange
paddy@10 251 var owner uuid.ID
paddy@10 252 var name, pushToken string
paddy@15 253 var lastSeen time.Time
paddy@10 254 var deviceType DeviceType
paddy@10 255
paddy@10 256 device.ID = uuid.NewID()
paddy@10 257
paddy@10 258 expectation := device
paddy@10 259 result := device
paddy@10 260
paddy@10 261 change.DeviceID = device.ID
paddy@10 262
paddy@10 263 if i&changeName != 0 {
paddy@10 264 name = fmt.Sprintf("name-%d", i)
paddy@10 265 change.Name = &name
paddy@10 266 expectation.Name = name
paddy@10 267 }
paddy@10 268 if i&changeOwner != 0 {
paddy@10 269 owner = uuid.NewID()
paddy@10 270 change.Owner = &owner
paddy@10 271 expectation.Owner = owner
paddy@10 272 }
paddy@10 273 if i&changeType != 0 {
paddy@10 274 deviceType = DeviceType(fmt.Sprintf("type-%d", i))
paddy@10 275 change.Type = &deviceType
paddy@10 276 expectation.Type = deviceType
paddy@10 277 }
paddy@10 278 if i&changeLastSeen != 0 {
paddy@10 279 lastSeen = time.Now().Add(time.Minute * time.Duration(i*-1))
paddy@10 280 change.LastSeen = &lastSeen
paddy@10 281 expectation.LastSeen = lastSeen
paddy@10 282 }
paddy@10 283 if i&changePushToken != 0 {
paddy@10 284 pushToken = fmt.Sprintf("push-token-%d", i)
paddy@10 285 change.PushToken = &pushToken
paddy@10 286 expectation.PushToken = pushToken
paddy@10 287 }
paddy@10 288 result = ApplyChange(result, change)
paddy@10 289 ok, field, expectedVal, resultVal := compareDevices(expectation, result)
paddy@10 290 if !ok {
paddy@10 291 t.Errorf("Expected %s of %s to be %v, got %v after applying DeviceChange %+v\n", field, device.Name, expectedVal, resultVal, change)
paddy@10 292 }
paddy@10 293
paddy@10 294 err = storer.CreateDevices([]Device{device}, ctx)
paddy@10 295 if err != nil {
paddy@10 296 t.Errorf("Unexpected error creating devices in %T: %+v\n", storer, err)
paddy@10 297 }
paddy@10 298
paddy@10 299 err = storer.UpdateDevice(change, ctx)
paddy@10 300 if err != nil {
paddy@10 301 t.Errorf("Unexpected error updating device in %T: %+v\n", storer, err)
paddy@10 302 }
paddy@10 303
paddy@10 304 retrieved, err := storer.GetDevices([]uuid.ID{device.ID}, ctx)
paddy@10 305 if err != nil {
paddy@10 306 t.Errorf("Unexpected error retrieving devices from %T: %+v\n", storer, err)
paddy@10 307 }
paddy@10 308 retrievedDevice, ok := retrieved[device.ID.String()]
paddy@10 309 if !ok {
paddy@10 310 t.Errorf("Expected retrieved devices to contain %s, got %+v from %T\n", device.Name, retrieved, storer)
paddy@10 311 }
paddy@10 312 ok, field, expectedVal, resultVal = compareDevices(expectation, retrievedDevice)
paddy@10 313 if !ok {
paddy@10 314 t.Errorf("Expected %s of %s to be %v, got %v from %T\n", field, device.Name, expectedVal, resultVal, storer)
paddy@10 315 }
paddy@10 316 }
paddy@10 317
paddy@10 318 err = factory.TeardownStorer(storer, ctx)
paddy@10 319 if err != nil {
paddy@10 320 t.Errorf("Error cleaning up after %T: %+v\n", storer, err)
paddy@10 321 }
paddy@10 322 }
paddy@10 323 }
paddy@11 324
paddy@11 325 func TestUpdateDeviceNotFound(t *testing.T) {
paddy@11 326 for _, factory := range storerFactories {
paddy@11 327 ctx := context.Background()
paddy@11 328 storer, err := factory.NewStorer(ctx)
paddy@11 329 if err != nil {
paddy@11 330 t.Fatalf("Fatal error creating Storer from %T: %+v\n", factory, err)
paddy@11 331 }
paddy@11 332
paddy@11 333 deviceID := uuid.NewID()
paddy@11 334 name := "my new name"
paddy@11 335 change := DeviceChange{DeviceID: deviceID, Name: &name}
paddy@11 336
paddy@11 337 err = storer.UpdateDevice(change, ctx)
paddy@11 338 if err != ErrDeviceNotFound {
paddy@11 339 t.Errorf("Expected error to be ErrDeviceNotFound, %T returned %+v\n", storer, err)
paddy@11 340 }
paddy@11 341
paddy@11 342 results, err := storer.GetDevices([]uuid.ID{deviceID}, ctx)
paddy@11 343 if err != nil {
paddy@11 344 t.Errorf("Error retrieving devices from %T: %+v\n", storer, err)
paddy@11 345 }
paddy@11 346 if len(results) != 0 {
paddy@11 347 t.Errorf("Expected no devices in %T, got %+v\n", storer, results)
paddy@11 348 }
paddy@11 349
paddy@11 350 err = factory.TeardownStorer(storer, ctx)
paddy@11 351 if err != nil {
paddy@11 352 t.Errorf("Error cleaning up after %T: %+v\n", storer, err)
paddy@11 353 }
paddy@11 354 }
paddy@11 355 }
paddy@14 356
paddy@14 357 func TestDeleteDevicesHappyPath(t *testing.T) {
paddy@14 358 for _, factory := range storerFactories {
paddy@14 359 ctx := context.Background()
paddy@14 360 storer, err := factory.NewStorer(ctx)
paddy@14 361 if err != nil {
paddy@14 362 t.Fatalf("Fatal error creating Storer from %T: %+v\n", factory, err)
paddy@14 363 }
paddy@14 364
paddy@14 365 owner1, owner2 := uuid.NewID(), uuid.NewID()
paddy@14 366
paddy@14 367 devices := []Device{
paddy@14 368 {ID: uuid.NewID(), Name: "Test 1", Owner: owner1, Type: TypeAndroidPhone, Created: time.Now(), LastSeen: time.Now(), PushToken: "test token"},
paddy@14 369 {ID: uuid.NewID(), Name: "Test 2", Owner: owner2, Type: TypeAndroidTablet, Created: time.Now(), LastSeen: time.Now(), PushToken: "test token"},
paddy@14 370 {ID: uuid.NewID(), Name: "Test 3", Owner: owner1, Type: TypeChromeExtension, Created: time.Now(), LastSeen: time.Now(), PushToken: "test token"},
paddy@14 371 }
paddy@14 372
paddy@14 373 err = storer.CreateDevices(devices, ctx)
paddy@14 374 if err != nil {
paddy@14 375 t.Errorf("Error creating devices in %T: %+v\n", storer, err)
paddy@14 376 }
paddy@14 377
paddy@14 378 err = storer.DeleteDevices([]uuid.ID{devices[0].ID, devices[1].ID}, ctx)
paddy@14 379 if err != nil {
paddy@14 380 t.Errorf("Error deleting devices from %T: %+v\n", storer, err)
paddy@14 381 }
paddy@14 382
paddy@14 383 results, err := storer.GetDevices([]uuid.ID{devices[0].ID, devices[1].ID, devices[2].ID}, ctx)
paddy@14 384 if err != nil {
paddy@14 385 t.Errorf("Unexpected error retrieving devices from %T: %+v\n", storer, err)
paddy@14 386 }
paddy@14 387
paddy@14 388 if len(results) != 1 {
paddy@14 389 t.Errorf("Expected %d results, got %d from %T\n", 1, len(results), storer)
paddy@14 390 }
paddy@14 391
paddy@14 392 device, ok := results[devices[0].ID.String()]
paddy@14 393 if ok {
paddy@14 394 t.Errorf("Retrieved first device (which was deleted!) from %T: %+v\n", storer, device)
paddy@14 395 }
paddy@14 396
paddy@14 397 device, ok = results[devices[1].ID.String()]
paddy@14 398 if ok {
paddy@14 399 t.Errorf("Retrieved second device (which was deleted!) from %T: %+v\n", storer, device)
paddy@14 400 }
paddy@14 401
paddy@14 402 device, ok = results[devices[2].ID.String()]
paddy@14 403 if !ok {
paddy@14 404 t.Errorf("Didn't retrieve third device (which wasn't deleted!) from %T. Got %+v\n", storer, results)
paddy@14 405 }
paddy@14 406
paddy@14 407 err = factory.TeardownStorer(storer, ctx)
paddy@14 408 if err != nil {
paddy@14 409 t.Errorf("Error cleaning up after %T: %+v\n", storer, err)
paddy@14 410 }
paddy@14 411 }
paddy@14 412 }